feat(sim): world, contact trace, SimTransport

This commit is contained in:
2026-10-17 16:06:51 -07:00
parent 1740acd625
commit 5dca574c50
6 changed files with 849 additions and 2 deletions
+18
View File
@@ -0,0 +1,18 @@
//! tines-sim: a std simulator for the tines shared-memory library.
//!
//! `SimWorld` is a shared, reference-counted simulation of nodes connected by a
//! `ContactTrace`. `SimTransport` implements the `tines_core::Transport` trait
//! on top of that world: outbound frames are enqueued into per-node inboxes,
//! and `SimWorld::advance` turns contact-state changes into `Contact`/`Lost`
//! events. Loss and duplication are driven by a deterministic, seeded RNG so
//! that convergence behaviour is reproducible.
#![deny(warnings)]
pub mod trace;
pub mod world;
pub mod transport;
pub use trace::{Contact, ContactTrace};
pub use transport::SimTransport;
pub use world::{SimWorld, WorldConfig};
+68
View File
@@ -0,0 +1,68 @@
//! Contact traces: a timeline of which nodes are within range of each other.
/// A single contact window between two nodes.
///
/// Nodes `a` and `b` are in contact during the half-open interval
/// `[start_ms, end_ms)`. Contacts are symmetric: a contact recorded as
/// `(a, b)` is equivalent to `(b, a)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Contact {
pub start_ms: u64,
pub end_ms: u64,
pub a: usize,
pub b: usize,
}
/// An ordered list of contact windows, as produced by the contact simulator.
#[derive(Clone, Debug, Default)]
pub struct ContactTrace {
pub contacts: Vec<Contact>,
}
impl ContactTrace {
/// Whether `a` and `b` are in contact at time `t`, for any contact
/// involving the pair in either order.
///
/// A contact counts during its half-open window: `start_ms <= t < end_ms`.
pub fn in_contact(&self, a: usize, b: usize, t: u64) -> bool {
self.contacts
.iter()
.any(|c| is_contact_pair(c, a, b) && in_window(c, t))
}
/// The last time any contact ends, or `0` if there are no contacts.
pub fn end(&self) -> u64 {
self.contacts.iter().map(|c| c.end_ms).max().unwrap_or(0)
}
}
/// True when `c` links `a` and `b` in either order.
fn is_contact_pair(c: &Contact, a: usize, b: usize) -> bool {
(c.a == a && c.b == b) || (c.a == b && c.b == a)
}
/// True when `t` falls inside the half-open window `[start, end)`.
fn in_window(c: &Contact, t: u64) -> bool {
c.start_ms <= t && t < c.end_ms
}
#[cfg(test)]
mod tests {
use super::{Contact, ContactTrace};
#[test]
fn in_contact_matches_both_orders() {
let mut trace = ContactTrace::default();
trace.contacts.push(Contact { start_ms: 100, end_ms: 200, a: 3, b: 7 });
assert!(!trace.in_contact(3, 7, 50)); // before the window
assert!(trace.in_contact(3, 7, 100)); // at the start edge
assert!(trace.in_contact(7, 3, 150)); // mid-window, either order
assert!(!trace.in_contact(3, 7, 200)); // at the end edge (half-open)
assert!(!trace.in_contact(3, 7, 300)); // after the window
}
#[test]
fn end_is_zero_when_empty() {
assert_eq!(ContactTrace::default().end(), 0);
}
}
+67
View File
@@ -0,0 +1,67 @@
//! SimTransport: a `Transport` implementation over a shared `SimWorld`.
//!
//! `SimTransport` is a datagram pipe bound to one node (`me`). Outbound frames
//! are checked against the live contact graph and the world's MTU, then
//! subjected to a loss roll and — on success — a duplication roll. All of this
//! mutates the shared `SimWorld`'s per-node mailboxes and frame counters, so
//! every participant shares one view of the network.
use crate::world::SimWorld;
use rand::Rng;
use std::cell::RefCell;
use std::rc::Rc;
use tines_core::transport::{Event, FrameBuf, SendError, Transport};
/// A node's view of a shared `SimWorld`.
pub struct SimTransport {
world: Rc<RefCell<SimWorld>>,
me: usize,
}
impl SimTransport {
/// Bind transport `me` to the shared `world`.
pub fn new(world: Rc<RefCell<SimWorld>>, me: usize) -> Self {
SimTransport { world, me }
}
}
impl Transport for SimTransport {
type Addr = usize;
/// Pop the next queued event for this node, if any.
fn poll(&mut self) -> Option<Event<Self::Addr>> {
self.world.borrow_mut().inboxes[self.me].pop_front()
}
/// Attempt to deliver `frame` to `to`.
///
/// Order of checks: reachability, then MTU, then the loss roll. A frame that
/// survives loss is enqueued; a surviving frame is duplicated with the dup
/// roll (counting each enqueue toward `frames_sent`).
fn send(&mut self, to: &Self::Addr, frame: &[u8]) -> Result<(), SendError> {
let mut w = self.world.borrow_mut();
if !w.trace.in_contact(self.me, *to, w.prev_t) {
return Err(SendError::Unreachable);
}
if frame.len() > w.cfg.mtu {
return Err(SendError::TooLarge);
}
if w.rng.gen::<f64>() < w.cfg.loss {
w.frames_dropped += 1;
return Ok(());
}
let buf = FrameBuf::from_slice(frame).map_err(|_| SendError::TooLarge)?;
w.inboxes[*to].push_back(Event::Frame(self.me, buf.clone()));
w.frames_sent += 1;
if w.rng.gen::<f64>() < w.cfg.dup {
w.inboxes[*to].push_back(Event::Frame(self.me, buf.clone()));
w.frames_sent += 1;
}
Ok(())
}
/// Maximum frame size in bytes this transport can send.
fn mtu(&self) -> usize {
self.world.borrow().cfg.mtu
}
}
+228
View File
@@ -0,0 +1,228 @@
//! World configuration and the shared `SimWorld`.
//!
//! `SimWorld` is the heart of the simulator: it owns the contact trace, a
//! per-node inbound mailbox, the last-advanced time, and a seeded RNG that
//! governs loss and duplication. It is always held behind `Rc<RefCell<..>>` so
//! that many `SimTransport`s can share a single world.
use crate::trace::ContactTrace;
use rand::{rngs::StdRng, SeedableRng};
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use tines_core::transport::Event;
/// World configuration for a `SimWorld`.
#[derive(Clone, Debug)]
pub struct WorldConfig {
pub nodes: usize,
pub mtu: usize,
pub loss: f64,
pub dup: f64,
pub seed: u64,
}
/// A shared simulation of `nodes` connected by a `ContactTrace`.
///
/// Every node owns an inbound mailbox of `Event`s. `advance` walks every
/// unordered pair, emitting `Contact`/`Lost` where the contact graph changed
/// since the last advance. `SimTransport` uses the same mailboxes and the same
/// RNG (held here) to deliver and drop frames.
pub struct SimWorld {
pub(crate) cfg: WorldConfig,
pub(crate) trace: ContactTrace,
pub(crate) inboxes: Vec<VecDeque<Event<usize>>>,
pub(crate) prev_t: u64,
pub(crate) rng: StdRng,
pub(crate) frames_sent: u64,
pub(crate) frames_dropped: u64,
}
impl SimWorld {
/// Build a world with empty mailboxes, `prev_t = 0`, and a seeded RNG.
pub fn new(cfg: WorldConfig, trace: ContactTrace) -> Rc<RefCell<SimWorld>> {
let seed = cfg.seed;
let inboxes = vec![VecDeque::new(); cfg.nodes];
let world = SimWorld {
cfg,
trace,
inboxes,
prev_t: 0,
rng: StdRng::seed_from_u64(seed),
frames_sent: 0,
frames_dropped: 0,
};
Rc::new(RefCell::new(world))
}
/// Advance the world to time `t`, emitting `Contact`/`Lost` for every pair
/// whose contact state changed since the previous advance.
pub fn advance(&mut self, t: u64) {
for a in 0..self.cfg.nodes {
for b in (a + 1)..self.cfg.nodes {
let old = self.trace.in_contact(a, b, self.prev_t);
let new = self.trace.in_contact(a, b, t);
if !old && new {
self.inboxes[a].push_back(Event::Contact(b));
self.inboxes[b].push_back(Event::Contact(a));
} else if old && !new {
self.inboxes[a].push_back(Event::Lost(b));
self.inboxes[b].push_back(Event::Lost(a));
}
}
}
self.prev_t = t;
}
/// Total frames successfully enqueued (duplication counts each enqueue).
pub fn frames_sent(&self) -> u64 {
self.frames_sent
}
/// Total frames dropped by the loss roll-out.
pub fn frames_dropped(&self) -> u64 {
self.frames_dropped
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::{SimWorld, WorldConfig};
use crate::trace::{Contact, ContactTrace};
use crate::transport::SimTransport;
use std::cell::RefCell;
use std::rc::Rc;
use tines_core::transport::{Event, SendError, Transport};
/// A 2-node world where nodes 0 and 1 are in contact during
/// `[start, end)`, with the given loss/dup/mtu/seed.
fn two_node_world(
mtu: usize,
loss: f64,
dup: f64,
seed: u64,
start: u64,
end: u64,
) -> Rc<RefCell<SimWorld>> {
let cfg = WorldConfig {
nodes: 2,
mtu,
loss,
dup,
seed,
};
let mut trace = ContactTrace::default();
trace
.contacts
.push(Contact { start_ms: start, end_ms: end, a: 0, b: 1 });
SimWorld::new(cfg, trace)
}
/// A snapshot of everything currently queued in `node`'s inbox.
fn inbox(world: &Rc<RefCell<SimWorld>>, node: usize) -> Vec<Event<usize>> {
world.borrow().inboxes[node].iter().cloned().collect()
}
/// The number of `Frame` events queued in `node`'s inbox.
fn frame_count(world: &Rc<RefCell<SimWorld>>, node: usize) -> usize {
world
.borrow()
.inboxes[node]
.iter()
.filter(|e| matches!(e, Event::Frame(_, _)))
.count()
}
#[test]
fn advance_emits_contact_and_lost_at_window_edges() {
let world = two_node_world(1500, 0.0, 0.0, 42, 100, 200);
// Before the window opens, neither mailbox has anything.
world.borrow_mut().advance(50);
assert_eq!(inbox(&world, 0), Vec::<Event<usize>>::new());
assert_eq!(inbox(&world, 1), Vec::<Event<usize>>::new());
// At the start edge both peers learn of the contact.
world.borrow_mut().advance(100);
assert_eq!(inbox(&world, 0), vec![Event::Contact(1)]);
assert_eq!(inbox(&world, 1), vec![Event::Contact(0)]);
// At the end edge both peers learn of the loss.
world.borrow_mut().advance(200);
assert_eq!(inbox(&world, 0), vec![Event::Contact(1), Event::Lost(1)]);
assert_eq!(inbox(&world, 1), vec![Event::Contact(0), Event::Lost(0)]);
}
#[test]
fn send_outside_contact_is_unreachable() {
let cfg = WorldConfig {
nodes: 2,
mtu: 1500,
loss: 0.0,
dup: 0.0,
seed: 42,
};
let world = SimWorld::new(cfg, ContactTrace::default());
let mut t0 = SimTransport::new(world.clone(), 0);
assert_eq!(t0.send(&1, &[1, 2, 3]), Err(SendError::Unreachable));
}
#[test]
fn send_over_mtu_is_too_large() {
let world = two_node_world(8, 0.0, 0.0, 42, 0, 1000);
let mut t0 = SimTransport::new(world.clone(), 0);
assert_eq!(t0.send(&1, &[0u8; 9]), Err(SendError::TooLarge));
}
#[test]
fn loss_1_drops_everything() {
let world = two_node_world(1500, 1.0, 0.0, 42, 0, 1000);
let mut t0 = SimTransport::new(world.clone(), 0);
for _ in 0..100 {
t0.send(&1, &[1, 2, 3]).unwrap();
}
assert_eq!(frame_count(&world, 1), 0);
assert_eq!(world.borrow().frames_dropped(), 100);
}
#[test]
fn dup_1_doubles_everything() {
let world = two_node_world(1500, 0.0, 1.0, 42, 0, 1000);
let mut t0 = SimTransport::new(world.clone(), 0);
for _ in 0..10 {
t0.send(&1, &[1, 2, 3]).unwrap();
}
assert_eq!(frame_count(&world, 1), 20);
}
#[test]
fn same_seed_same_drops() {
let cfg = WorldConfig {
nodes: 2,
mtu: 1500,
loss: 0.5,
dup: 0.0,
seed: 1234,
};
let make_trace = || {
let mut trace = ContactTrace::default();
trace
.contacts
.push(Contact { start_ms: 0, end_ms: 1000, a: 0, b: 1 });
trace
};
let w1 = SimWorld::new(cfg.clone(), make_trace());
let w2 = SimWorld::new(cfg.clone(), make_trace());
let mut t1 = SimTransport::new(w1.clone(), 0);
let mut t2 = SimTransport::new(w2.clone(), 0);
for _ in 0..50 {
t1.send(&1, &[1]).unwrap();
t2.send(&1, &[1]).unwrap();
}
assert_eq!(w1.borrow().frames_dropped(), w2.borrow().frames_dropped());
}
}