feat(sim): harness and convergence scenarios
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
//! Deterministic convergence scenarios for the `Sim` harness.
|
||||
//!
|
||||
//! Every scenario is built from a `WorldConfig` + `ContactTrace`, then stepped
|
||||
//! with `Sim` and asserted against. Because loss/dup/seed are seeded and the
|
||||
//! contact graph is fixed, the outcomes below are reproducible run to run.
|
||||
|
||||
use tines_core::LatestWins;
|
||||
use tines_sim::{Contact, ContactTrace, Sim, WorldConfig};
|
||||
|
||||
/// Build the world for test 1: two nodes, a single `[0, 5000)` contact.
|
||||
#[test]
|
||||
fn two_nodes_one_contact_converge() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 2,
|
||||
mtu: 1500,
|
||||
loss: 0.0,
|
||||
dup: 0.0,
|
||||
seed: 0,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 5000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
for i in 0..10 {
|
||||
sim.observe(0, format!("n0-{}", i).as_bytes(), format!("p{}", i).as_bytes());
|
||||
}
|
||||
for i in 0..10 {
|
||||
sim.observe(1, format!("n1-{}", i).as_bytes(), format!("p{}", i).as_bytes());
|
||||
}
|
||||
sim.run_until(5000);
|
||||
|
||||
assert!(sim.converged(&[0, 1]), "both nodes must hold every observation");
|
||||
assert_eq!(sim.id_set(0).len(), 20, "each node stores 10 + 10 obs");
|
||||
assert_eq!(sim.id_set(1).len(), 20);
|
||||
}
|
||||
|
||||
/// Test 2: a node two hops away must still have the first node's observations
|
||||
/// even though the two never met directly.
|
||||
#[test]
|
||||
fn relay_through_middle_node() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 3,
|
||||
mtu: 1500,
|
||||
loss: 0.0,
|
||||
dup: 0.0,
|
||||
seed: 0,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 3000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 4000,
|
||||
end_ms: 7000,
|
||||
a: 1,
|
||||
b: 2,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
for i in 0..5 {
|
||||
sim.observe(0, format!("a-{}", i).as_bytes(), format!("a-p{}", i).as_bytes());
|
||||
}
|
||||
sim.run_until(7000);
|
||||
|
||||
let from_0 = sim.id_set(0);
|
||||
let node_2 = sim.id_set(2);
|
||||
// 0 and 2 never shared a contact, yet 2 received everything through 1.
|
||||
for id in &from_0 {
|
||||
assert!(node_2.contains(id), "node 2 is missing node 0's observation {id:?}");
|
||||
}
|
||||
assert_eq!(from_0.len(), 5, "node 0 observed 5 things");
|
||||
}
|
||||
|
||||
/// Test 3: two isolated components each converge internally but the whole graph
|
||||
/// does not, and the components are reported exactly.
|
||||
#[test]
|
||||
fn disconnected_component_does_not_converge() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 4,
|
||||
mtu: 1500,
|
||||
loss: 0.0,
|
||||
dup: 0.0,
|
||||
seed: 0,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 5000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 5000,
|
||||
a: 2,
|
||||
b: 3,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
sim.observe(0, b"comp-A", b"value-A");
|
||||
sim.observe(2, b"comp-B", b"value-B");
|
||||
sim.run_until(5000);
|
||||
|
||||
assert_eq!(
|
||||
sim.components(),
|
||||
vec![vec![0, 1], vec![2, 3]],
|
||||
"the contact graph splits into two pairs"
|
||||
);
|
||||
assert!(!sim.converged(&[0, 1, 2, 3]), "cross-component never converges");
|
||||
assert!(sim.converged(&[0, 1]), "component A converges");
|
||||
assert!(sim.converged(&[2, 3]), "component B converges");
|
||||
}
|
||||
|
||||
/// Test 4: a lossy, duplicate-prone link still converges given enough time.
|
||||
#[test]
|
||||
fn lossy_link_still_converges_given_time() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 2,
|
||||
mtu: 1500,
|
||||
loss: 0.3,
|
||||
dup: 0.1,
|
||||
seed: 7,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 60_000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
for i in 0..50 {
|
||||
sim.observe(0, format!("l0-{}", i).as_bytes(), format!("l0-p{}", i).as_bytes());
|
||||
sim.observe(1, format!("l1-{}", i).as_bytes(), format!("l1-p{}", i).as_bytes());
|
||||
}
|
||||
sim.run_until(60_000);
|
||||
|
||||
assert!(
|
||||
sim.converged(&[0, 1]),
|
||||
"a lossy link converges once the summary-driven retransmits drain the queue"
|
||||
);
|
||||
assert_eq!(sim.id_set(0).len(), 100);
|
||||
assert_eq!(sim.id_set(1).len(), 100);
|
||||
}
|
||||
|
||||
/// Test 5: the belief age tracks true elapsed wall-clock time across two hops.
|
||||
///
|
||||
/// The observation is made at `t=1000` and the contact graph is
|
||||
/// `[0,3000)` between 0-1 and `[4000,7000)` between 1-2, so by `t=7000` node 2
|
||||
/// should believe `x` is roughly `7000 - 1000 = 6000` ms old.
|
||||
#[test]
|
||||
fn age_tracks_true_elapsed_across_hops() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 3,
|
||||
mtu: 1500,
|
||||
loss: 0.0,
|
||||
dup: 0.0,
|
||||
seed: 0,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 3000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 4000,
|
||||
end_ms: 7000,
|
||||
a: 1,
|
||||
b: 2,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
sim.run_until(1000);
|
||||
sim.observe(0, b"x", b"hello");
|
||||
sim.run_until(7000);
|
||||
|
||||
let belief = sim
|
||||
.nodes
|
||||
.get(2)
|
||||
.and_then(|n| n.belief(b"x", &LatestWins, 7000))
|
||||
.expect("node 2 should hold a belief for \"x\"");
|
||||
|
||||
let age = belief.age_ms;
|
||||
assert!(
|
||||
(age as i64 - 6000).abs() <= 20,
|
||||
"age {age} must be within +/-{} ms of 6000 (true elapsed across two hops)",
|
||||
2 * sim.step_ms
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 6: payloads larger than the MTU are fragmented across frames and still
|
||||
/// converge.
|
||||
#[test]
|
||||
fn large_payloads_fragment_over_small_mtu() {
|
||||
let cfg = WorldConfig {
|
||||
nodes: 2,
|
||||
mtu: 256,
|
||||
loss: 0.0,
|
||||
dup: 0.0,
|
||||
seed: 9,
|
||||
};
|
||||
let mut trace = ContactTrace::default();
|
||||
trace.contacts.push(Contact {
|
||||
start_ms: 0,
|
||||
end_ms: 20_000,
|
||||
a: 0,
|
||||
b: 1,
|
||||
});
|
||||
|
||||
let mut sim = Sim::new(cfg, trace);
|
||||
for i in 0..20 {
|
||||
let topic0 = format!("n0-{}", i);
|
||||
let topic1 = format!("n1-{}", i);
|
||||
let payload = vec![0xABu8; 1024];
|
||||
sim.observe(0, topic0.as_bytes(), &payload);
|
||||
sim.observe(1, topic1.as_bytes(), &payload);
|
||||
}
|
||||
sim.run_until(20_000);
|
||||
|
||||
assert!(
|
||||
sim.converged(&[0, 1]),
|
||||
"1 KiB payloads fragmented over a 256-byte MTU still converge"
|
||||
);
|
||||
assert_eq!(sim.id_set(0).len(), 40);
|
||||
assert_eq!(sim.id_set(1).len(), 40);
|
||||
}
|
||||
Reference in New Issue
Block a user