use std::collections::{BTreeMap, BinaryHeap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use crate::executor::ActorExecutor;
use crate::queue::Task;
use crate::registry::ActorState;
use crate::supervisor::{
ChildSpec, ExitReason, RestartPolicy, SupervisionStrategy, SupervisorTree,
};
use crate::{ActorId, ActorRegistry, MailboxConfig, Message, MessagePayload, Priority};
pub mod clock;
pub mod executor;
pub mod faults;
pub mod scheduler;
pub use clock::{SimClock, SimInstant};
pub use executor::{ExecRecord, ExecTag, SimExecutor, SIM_CRASH_MESSAGE};
pub use faults::FaultConfig;
pub use scheduler::SchedulingPolicy;
use faults::SimRng;
use scheduler::{ReadySet, SimTask};
pub const DEFAULT_STEP_BUDGET: u64 = 4_000_000;
const TICK: Duration = Duration::from_millis(1);
const DUPLICATE_GAP_TICKS: u64 = 2;
const SIM_ID_BASE: u128 = 0xA77C_70B1_5160_0000;
pub(crate) fn sim_actor_id(ordinal: u64, incarnation: u64) -> ActorId {
ActorId(Uuid::from_u128(
SIM_ID_BASE | ((ordinal as u128) << 64) | incarnation as u128,
))
}
#[derive(Debug, Clone)]
pub struct SimConfig {
pub actors: u64,
pub messages: u64,
pub mailbox: MailboxConfig,
pub policy: SchedulingPolicy,
pub faults: FaultConfig,
pub max_restarts: u32,
pub injection_rate: f64,
pub processing_per_tick: u64,
pub blocked_retries_per_tick: u64,
}
impl Default for SimConfig {
fn default() -> Self {
Self {
actors: 4,
messages: 256,
mailbox: MailboxConfig {
capacity: 64,
priority_queue: true,
backpressure_threshold: 0.8,
},
policy: SchedulingPolicy::Random,
faults: FaultConfig::default(),
max_restarts: 3,
injection_rate: 4.0,
processing_per_tick: 4,
blocked_retries_per_tick: 64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RejectReason {
Fault,
DeadTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceOp {
Spawn {
ord: u64,
},
Send {
mid: u64,
to: u64,
},
BatchSend {
first: u64,
len: u64,
to: u64,
},
Reject {
mid: u64,
to: u64,
reason: RejectReason,
},
Deliver {
first: u64,
len: u64,
to: u64,
},
Blocked {
first: u64,
len: u64,
to: u64,
},
Process {
mid: u64,
to: u64,
},
ProcessSystem {
to: u64,
},
Crash {
to: u64,
dropped: u64,
},
Discard {
first: u64,
len: u64,
to: u64,
},
Restart {
to: u64,
allowed: bool,
},
Kill {
to: u64,
dropped: u64,
},
}
impl TraceOp {
fn encode_into(&self, buf: &mut Vec<u8>) {
fn push(buf: &mut Vec<u8>, tag: u8, fields: &[u64]) {
buf.push(tag);
for field in fields {
buf.extend_from_slice(&field.to_le_bytes());
}
}
match self {
TraceOp::Spawn { ord } => push(buf, 0, &[*ord]),
TraceOp::Send { mid, to } => push(buf, 1, &[*mid, *to]),
TraceOp::BatchSend { first, len, to } => push(buf, 2, &[*first, *len, *to]),
TraceOp::Reject { mid, to, reason } => push(
buf,
3,
&[*mid, *to, match reason {
RejectReason::Fault => 0,
RejectReason::DeadTarget => 1,
}],
),
TraceOp::Deliver { first, len, to } => push(buf, 4, &[*first, *len, *to]),
TraceOp::Blocked { first, len, to } => push(buf, 5, &[*first, *len, *to]),
TraceOp::Process { mid, to } => push(buf, 6, &[*mid, *to]),
TraceOp::ProcessSystem { to } => push(buf, 7, &[*to]),
TraceOp::Crash { to, dropped } => push(buf, 8, &[*to, *dropped]),
TraceOp::Discard { first, len, to } => push(buf, 9, &[*first, *len, *to]),
TraceOp::Restart { to, allowed } => push(buf, 10, &[*to, u64::from(*allowed)]),
TraceOp::Kill { to, dropped } => push(buf, 11, &[*to, *dropped]),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SimStats {
pub injected: u64,
pub rejected: u64,
pub delivered: u64,
pub processed: u64,
pub crashed: u64,
pub restarted: u64,
pub escalated: u64,
pub dropped: u64,
pub blocked: u64,
pub unblocked: u64,
pub delayed: u64,
pub duplicates: u64,
pub system_processed: u64,
}
#[derive(Debug, Clone)]
pub struct SimOutcome {
pub seed: u64,
pub quiesced: bool,
pub steps: u64,
pub trace_hash: u64,
pub trace_ops: usize,
pub stats: SimStats,
}
#[derive(Debug, Default, Clone)]
struct ActorLedger {
delivered: u64,
consumed: u64,
dropped: u64,
queued: u64,
incarnation: u64,
alive: bool,
}
#[derive(Debug, Clone)]
struct DeliverPayload {
ord: u64,
msgs: Vec<Message>,
mids: Vec<u64>,
was_blocked: bool,
}
#[derive(Debug, Clone)]
struct DeliverLater {
at: u64,
seq: u64,
payload: DeliverPayload,
}
impl PartialEq for DeliverLater {
fn eq(&self, other: &Self) -> bool {
self.at == other.at && self.seq == other.seq
}
}
impl Eq for DeliverLater {}
impl PartialOrd for DeliverLater {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for DeliverLater {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(other.at, other.seq).cmp(&(self.at, self.seq))
}
}
pub struct Sim {
seed: u64,
config: SimConfig,
rng: SimRng,
clock: Arc<SimClock>,
registry: Arc<ActorRegistry>,
executor: Arc<SimExecutor>,
executor_dyn: Arc<dyn ActorExecutor>,
tree: SupervisorTree,
root: ActorId,
actors: Vec<ActorId>,
ordinals: BTreeMap<u128, u64>,
ledger: Vec<ActorLedger>,
ready: ReadySet,
delayed: BinaryHeap<DeliverLater>,
blocked: VecDeque<DeliverPayload>,
next_seq: u64,
next_mid: u64,
mid_times: Vec<u32>,
mid_lost: Vec<bool>,
tick: u64,
trace: Vec<TraceOp>,
stats: SimStats,
}
impl Sim {
pub fn new(seed: u64) -> Sim {
Self::with_config(seed, SimConfig::default())
}
pub fn with_config(seed: u64, config: SimConfig) -> Sim {
let mailbox_config = config.mailbox.clone();
let n_actors = config.actors;
let n_messages = config.messages;
let registry = Arc::new(ActorRegistry::with_mailbox_config(mailbox_config));
let executor = Arc::new(SimExecutor::new());
let executor_dyn: Arc<dyn ActorExecutor> = executor.clone();
let tree = SupervisorTree::new(SupervisionStrategy::one_for_one(
config.max_restarts,
Duration::from_secs(3600),
));
let root = tree.root();
let mut sim = Sim {
seed,
config,
rng: SimRng::new(seed),
clock: Arc::new(SimClock::new()),
registry,
executor,
executor_dyn,
tree,
root,
actors: Vec::with_capacity(n_actors as usize),
ordinals: BTreeMap::new(),
ledger: Vec::with_capacity(n_actors as usize),
ready: ReadySet::new(),
delayed: BinaryHeap::new(),
blocked: VecDeque::new(),
next_seq: 0,
next_mid: 0,
mid_times: vec![0; n_messages as usize],
mid_lost: vec![false; n_messages as usize],
tick: 0,
trace: Vec::new(),
stats: SimStats::default(),
};
for ord in 0..n_actors {
sim.spawn_actor(ord);
}
sim
}
pub fn config_mut(&mut self) -> &mut SimConfig {
&mut self.config
}
pub fn clock(&self) -> &SimClock {
&self.clock
}
pub fn registry(&self) -> &Arc<ActorRegistry> {
&self.registry
}
pub fn executor(&self) -> &SimExecutor {
&self.executor
}
pub fn trace(&self) -> &[TraceOp] {
&self.trace
}
pub fn trace_hash(&self) -> u64 {
let mut buf = Vec::with_capacity(self.trace.len() * 40);
for op in &self.trace {
op.encode_into(&mut buf);
}
fnv1a(&buf)
}
pub fn stats(&self) -> SimStats {
self.stats
}
fn spawn_actor(&mut self, ord: u64) {
let id = sim_actor_id(ord, 0);
let name = actor_name(ord, 0);
self.registry
.register_named(id, Some(name))
.expect("sim actor ids are unique");
self.tree
.start_child_under(
self.root,
ChildSpec::new(supervisor_name(ord)).restart_policy(RestartPolicy::Permanent),
)
.expect("sim child names are unique");
self.actors.push(id);
self.ordinals.insert(id.0.as_u128(), ord);
self.ledger.push(ActorLedger {
incarnation: 0,
alive: true,
..ActorLedger::default()
});
self.trace.push(TraceOp::Spawn { ord });
let start = Message {
sender: None,
payload: MessagePayload::Start,
priority: Priority::Normal,
};
self.deliver_or_block(ord, vec![start], Vec::new(), 0);
}
fn deliver_or_block(&mut self, ord: u64, msgs: Vec<Message>, mids: Vec<u64>, delay: u64) {
if delay > 0 {
self.next_seq += 1;
self.delayed.push(DeliverLater {
at: self.tick + delay,
seq: self.next_seq,
payload: DeliverPayload {
ord,
msgs,
mids,
was_blocked: false,
},
});
return;
}
self.attempt_delivery(ord, msgs, mids, false);
}
fn attempt_delivery(
&mut self,
ord: u64,
msgs: Vec<Message>,
mids: Vec<u64>,
was_blocked: bool,
) {
let id = self.actors[ord as usize];
let state = self.registry.get_state(&id);
if matches!(
state,
None | Some(ActorState::Stopped) | Some(ActorState::Failed)
) {
for &mid in &mids {
self.mark_lost(mid);
self.stats.rejected += 1;
self.trace.push(TraceOp::Reject {
mid,
to: ord,
reason: RejectReason::DeadTarget,
});
}
return;
}
let mailbox = self
.registry
.get_mailbox(&id)
.expect("live ord has a mailbox");
if mailbox.remaining_capacity() < msgs.len() {
self.stats.blocked += 1;
if let Some(&first) = mids.first() {
if !was_blocked {
self.trace.push(TraceOp::Blocked {
first,
len: mids.len() as u64,
to: ord,
});
}
}
self.blocked.push_back(DeliverPayload {
ord,
msgs,
mids,
was_blocked: true,
});
return;
}
for msg in &msgs {
mailbox
.try_send(msg.clone())
.expect("capacity precondition holds");
}
let priority = msgs
.iter()
.map(|m| m.priority)
.max()
.unwrap_or(Priority::Normal);
let task = Task {
actor_id: id,
message: msgs[0].clone(),
priority,
additional_messages: msgs[1..].to_vec(),
};
let ledger = &mut self.ledger[ord as usize];
ledger.delivered += msgs.len() as u64;
ledger.queued += mids.len() as u64;
self.stats.delivered += msgs.len() as u64;
if was_blocked {
self.stats.unblocked += 1;
}
if let Some(&first) = mids.first() {
self.trace.push(TraceOp::Deliver {
first,
len: mids.len() as u64,
to: ord,
});
}
self.ready.push(SimTask {
ord,
task,
mids,
});
}
fn inject_workload(&mut self) {
let budget = self.mid_times.len() as u64;
if self.next_mid >= budget {
return;
}
let rate = self.config.injection_rate.max(0.0);
let base = rate as u64;
let frac = rate - base as f64;
let mut attempts = base + u64::from(frac > 0.0 && self.rng.roll(frac));
attempts = attempts.max(1); for _ in 0..attempts {
if self.next_mid >= budget {
return;
}
self.inject_one();
}
}
fn inject_one(&mut self) {
let budget = self.mid_times.len() as u64;
let to = self.rng.below(self.config.actors);
let remaining = budget - self.next_mid;
let mut len = 1usize;
if remaining > 1 && self.rng.roll(1.0 / 8.0) {
let max = remaining.min(3);
len = self.rng.range(2, max) as usize;
}
let priority = if self.rng.roll(0.1) {
Priority::High
} else {
Priority::Normal
};
let first = self.next_mid;
self.next_mid += len as u64;
self.stats.injected += len as u64;
let mids: Vec<u64> = (first..first + len as u64).collect();
if len > 1 {
self.trace.push(TraceOp::BatchSend {
first,
len: len as u64,
to,
});
} else {
self.trace.push(TraceOp::Send { mid: first, to });
}
if self.rng.roll(self.config.faults.send_reject_prob) {
for &mid in &mids {
self.mark_lost(mid);
self.stats.rejected += 1;
self.trace.push(TraceOp::Reject {
mid,
to,
reason: RejectReason::Fault,
});
}
return;
}
let delay = if self.rng.roll(self.config.faults.delay_prob) {
self.rng.range(
self.config.faults.delay_ticks_min,
self.config.faults.delay_ticks_max,
)
} else {
0
};
let duplicate = self.rng.roll(self.config.faults.duplicate_prob);
let msgs: Vec<Message> = mids
.iter()
.map(|mid| Message {
sender: None,
payload: MessagePayload::Custom(mid.to_le_bytes().to_vec()),
priority,
})
.collect();
self.deliver_or_block(to, msgs.clone(), mids.clone(), delay);
if duplicate {
self.stats.duplicates += 1;
self.deliver_or_block(to, msgs, mids, delay + DUPLICATE_GAP_TICKS);
}
}
fn deliver_due(&mut self) {
while let Some(later) = self.delayed.peek() {
if later.at > self.tick {
break;
}
let later = self.delayed.pop().expect("peeked");
self.stats.delayed += 1;
self.attempt_delivery(
later.payload.ord,
later.payload.msgs,
later.payload.mids,
later.payload.was_blocked,
);
}
}
fn retry_blocked(&mut self) {
let mut attempts = self
.config
.blocked_retries_per_tick
.min(self.blocked.len() as u64);
while attempts > 0 {
attempts -= 1;
let payload = self.blocked.pop_front().expect("checked non-empty");
self.attempt_delivery(
payload.ord,
payload.msgs,
payload.mids,
payload.was_blocked,
);
}
}
pub fn run(&mut self, steps: u64) -> SimOutcome {
let mut executed = 0u64;
for _ in 0..steps {
if self.is_quiescent() {
break;
}
executed += 1;
self.tick += 1;
self.clock.advance(TICK);
self.inject_workload();
self.deliver_due();
self.retry_blocked();
for _ in 0..self.config.processing_per_tick {
if let Some(sim_task) = self
.ready
.pick(self.config.policy, &mut self.rng, self.tick)
{
self.process_chosen(sim_task);
} else {
break;
}
}
self.audit_counts();
}
let quiesced = self.is_quiescent();
if quiesced {
self.audit_identities();
}
SimOutcome {
seed: self.seed,
quiesced,
steps: executed,
trace_hash: self.trace_hash(),
trace_ops: self.trace.len(),
stats: self.stats,
}
}
pub fn run_to_quiescence(&mut self) -> SimOutcome {
self.run(DEFAULT_STEP_BUDGET)
}
fn process_chosen(&mut self, sim_task: SimTask) {
let ord = sim_task.ord();
let id = sim_task.task.actor_id;
let app_len = sim_task.app_len() as u64;
self.ledger[ord as usize].queued -= app_len;
let state = self.registry.get_state(&id);
if !matches!(
state,
Some(ActorState::Creating) | Some(ActorState::Running)
) {
for &mid in &sim_task.mids {
self.mark_lost(mid);
}
if let Some(&first) = sim_task.mids.first() {
self.trace.push(TraceOp::Discard {
first,
len: app_len,
to: ord,
});
}
self.executor.arm_crash(false);
return;
}
let crash = app_len > 0 && self.rng.roll(self.config.faults.crash_prob);
self.executor.arm_crash(crash);
let registry = &self.registry;
let executor_dyn = &self.executor_dyn;
let process = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::ActorScheduler::sim_process_task(registry, sim_task.task, Some(executor_dyn))
}));
let processed = match process {
Ok(n) => n,
Err(payload) => {
if payload
.downcast_ref::<&str>()
.is_none_or(|m| !m.contains(SIM_CRASH_MESSAGE))
&& payload
.downcast_ref::<String>()
.is_none_or(|m| !m.contains(SIM_CRASH_MESSAGE))
{
std::panic::resume_unwind(payload);
}
0
}
};
let records = self.executor.drain_records();
self.executor.arm_crash(false);
if !crash {
debug_assert_eq!(
processed as usize,
records.len(),
"worker counter and observation records must agree"
);
}
for record in records {
let record_ord = self
.ordinals
.get(&record.actor.0.as_u128())
.copied()
.expect("records only reference current incarnations");
self.ledger[record_ord as usize].consumed += 1;
match record.tag {
ExecTag::Custom(mid) => {
self.mid_times[mid as usize] = self.mid_times[mid as usize].saturating_add(1);
self.stats.processed += 1;
self.trace.push(TraceOp::Process {
mid,
to: record_ord,
});
}
ExecTag::System => {
self.stats.system_processed += 1;
self.trace.push(TraceOp::ProcessSystem { to: record_ord });
}
}
}
if crash {
self.stats.crashed += 1;
self.settle_crash(ord, &sim_task.mids);
self.handle_restart(ord);
}
}
fn settle_crash(&mut self, ord: u64, task_mids: &[u64]) {
let id = self.actors[ord as usize];
let mailbox_len = self
.registry
.get_mailbox(&id)
.map(|m| m.len())
.unwrap_or(0) as u64;
let ledger = &mut self.ledger[ord as usize];
let residual = ledger
.delivered
.saturating_sub(ledger.consumed)
.saturating_sub(ledger.dropped);
let destroyed = residual.saturating_sub(mailbox_len);
ledger.dropped += destroyed;
self.stats.dropped += destroyed;
for &mid in task_mids {
if self.mid_times[mid as usize] == 0 {
self.mark_lost(mid);
}
}
self.trace.push(TraceOp::Crash {
to: ord,
dropped: destroyed,
});
}
fn handle_restart(&mut self, ord: u64) {
let name = supervisor_name(ord);
let result = futures::executor::block_on(self.tree.handle_child_exit(
self.root,
&name,
ExitReason::Error(SIM_CRASH_MESSAGE.to_string()),
));
match result {
Ok(()) => {
self.stats.restarted += 1;
self.trace.push(TraceOp::Restart { to: ord, allowed: true });
self.respawn(ord);
}
Err(_) => {
self.trace
.push(TraceOp::Restart { to: ord, allowed: false });
self.kill(ord);
}
}
}
fn respawn(&mut self, ord: u64) {
{
let ledger = &mut self.ledger[ord as usize];
ledger.incarnation += 1;
}
let inc = self.ledger[ord as usize].incarnation;
let old = self.actors[ord as usize];
let _ = self.registry.unregister(&old);
self.ordinals.remove(&old.0.as_u128());
if let Some(supervisor) = self.tree.get_supervisor_mut(&self.root) {
let _ = supervisor.mark_child_running(&supervisor_name(ord));
}
let id = sim_actor_id(ord, inc);
self.registry
.register_named(id, Some(actor_name(ord, inc)))
.expect("fresh incarnation id is unique");
self.actors[ord as usize] = id;
self.ordinals.insert(id.0.as_u128(), ord);
self.trace.push(TraceOp::Spawn { ord });
let start = Message {
sender: None,
payload: MessagePayload::Start,
priority: Priority::Normal,
};
self.deliver_or_block(ord, vec![start], Vec::new(), 0);
}
fn kill(&mut self, ord: u64) {
let id = self.actors[ord as usize];
let dropped = self
.registry
.get_mailbox(&id)
.map(|m| {
let n = m.len() as u64;
m.clear();
n
})
.unwrap_or(0);
let ledger = &mut self.ledger[ord as usize];
ledger.dropped += dropped;
ledger.alive = false;
self.stats.dropped += dropped;
self.stats.escalated += 1;
let _ = self.registry.unregister(&id);
self.ordinals.remove(&id.0.as_u128());
self.trace.push(TraceOp::Kill { to: ord, dropped });
}
fn mark_lost(&mut self, mid: u64) {
if let Some(slot) = self.mid_lost.get_mut(mid as usize) {
*slot = true;
}
}
fn is_quiescent(&self) -> bool {
self.next_mid >= self.mid_times.len() as u64
&& self.delayed.is_empty()
&& self.blocked.is_empty()
&& self.ready.is_empty()
&& self.ready.total_queued_msgs() == 0
&& self.ledger.iter().zip(self.actors.iter()).all(|(led, id)| {
!led.alive
|| self
.registry
.get_mailbox(id)
.map(|m| m.is_empty())
.unwrap_or(true)
})
}
fn audit_counts(&self) {
for (ord, led) in self.ledger.iter().enumerate() {
if !led.alive {
continue;
}
let Some(mailbox) = self.registry.get_mailbox(&self.actors[ord]) else {
continue;
};
let expected = led
.delivered
.saturating_sub(led.consumed)
.saturating_sub(led.dropped);
assert_eq!(
mailbox.len() as u64,
expected,
"seed {}: slot/permit conservation violated for actor {ord} \
(delivered {}, consumed {}, dropped {}, queued {}, actual len {})",
self.seed,
led.delivered,
led.consumed,
led.dropped,
led.queued,
mailbox.len()
);
}
if self.config.max_restarts > 0 {
let restarts = self
.tree
.get_supervisor(&self.root)
.expect("root exists")
.count_children()
.total_restarts;
let budget = u64::from(self.config.max_restarts) * self.config.actors;
assert!(
restarts <= budget,
"seed {}: aggregate restarts {restarts} exceed per-child budget {} × {} actors",
self.seed,
self.config.max_restarts,
self.config.actors
);
let per_child_allowed = self.trace.iter().fold(
std::collections::HashMap::new(),
|mut acc: std::collections::HashMap<u64, u32>, op| {
if let TraceOp::Restart { to, allowed } = op {
let entry = acc.entry(*to).or_insert(0);
if *allowed {
*entry += 1;
assert!(
*entry <= self.config.max_restarts,
"seed {}: child {to} restarted {} times, exceeding max_restarts {}",
self.seed,
entry,
self.config.max_restarts
);
}
}
acc
},
);
debug_assert!(per_child_allowed.len() <= self.config.actors as usize);
}
}
fn audit_identities(&self) {
for (mid, ×) in self
.mid_times
.iter()
.take(self.next_mid as usize)
.enumerate()
{
let lost = self.mid_lost[mid];
assert!(
times >= 1 || lost,
"seed {}: message {mid} neither processed nor accounted lost",
self.seed
);
if self.config.faults.duplicate_prob == 0.0 {
assert!(
times <= 1,
"seed {}: message {mid} processed {times}× (exactly-once violated)",
self.seed
);
}
}
}
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn actor_name(ord: u64, inc: u64) -> String {
format!("sim-{ord}-v{inc}")
}
fn supervisor_name(ord: u64) -> String {
format!("sim-{ord}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a_known_vectors() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn sim_actor_ids_deterministic_and_unique() {
assert_eq!(sim_actor_id(3, 0), sim_actor_id(3, 0));
assert_ne!(sim_actor_id(1, 0), sim_actor_id(2, 0));
assert_ne!(sim_actor_id(1, 0), sim_actor_id(1, 1));
}
#[test]
fn fault_free_sim_quiesces_exactly_once() {
let mut config = SimConfig::default();
config.actors = 4;
config.messages = 256;
let mut sim = Sim::with_config(1, config);
let outcome = sim.run_to_quiescence();
assert!(outcome.quiesced, "fault-free workload must drain");
assert_eq!(outcome.stats.injected, 256);
assert_eq!(outcome.stats.processed, 256, "exactly-once end to end");
assert_eq!(outcome.stats.rejected + outcome.stats.dropped, 0);
assert_eq!(outcome.stats.system_processed, 4);
}
#[test]
fn deterministic_same_seed_same_hash() {
let build = || {
let mut config = SimConfig::default();
config.messages = 128;
config.faults = FaultConfig::chaos(0.05);
Sim::with_config(777, config)
};
let a = build().run_to_quiescence();
let b = build().run_to_quiescence();
assert_eq!(a.trace_hash, b.trace_hash);
assert_eq!(a.trace_ops, b.trace_ops);
assert_eq!(a.stats, b.stats);
}
#[test]
fn chaotic_sim_quiesces_with_restart_accounting() {
let mut config = SimConfig::default();
config.messages = 512;
config.faults = FaultConfig::chaos(0.02);
let mut sim = Sim::with_config(9, config);
let outcome = sim.run_to_quiescence();
assert!(outcome.quiesced);
assert!(outcome.stats.crashed > 0, "chaos config must crash actors");
assert!(outcome.stats.processed > 0);
}
}