826 lines
27 KiB
Rust
826 lines
27 KiB
Rust
|
|
use swactor_gossip::properties::*;
|
||
|
|
use swactor_gossip::sim::{run_simulation, SimConfig, Topology};
|
||
|
|
use swactor_gossip::trace::SimulationTrace;
|
||
|
|
|
||
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
fn test_data(n: usize) -> Vec<(String, Vec<u8>)> {
|
||
|
|
(0..n)
|
||
|
|
.map(|i| (format!("key-{i}"), format!("value-{i}").into_bytes()))
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn run_and_analyze(config: SimConfig) -> (SimulationTrace, GossipMetrics) {
|
||
|
|
let trace = run_simulation(config);
|
||
|
|
let metrics = analyze(&trace);
|
||
|
|
(trace, metrics)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Reliability (3) ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn all_nodes_receive_all_keys_in_ring_1000() {
|
||
|
|
// FullMesh 100 nodes converges in ~O(log N) rounds, well within 30 rounds.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-100".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
assert!(
|
||
|
|
(metrics.delivery_ratio - 1.0).abs() < 1e-9,
|
||
|
|
"delivery_ratio = {}, expected 1.0",
|
||
|
|
metrics.delivery_ratio
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn all_nodes_receive_all_keys_in_star_1000() {
|
||
|
|
// Full-mesh at 100 nodes: each node picks 1 of 99 peers, so with parallel
|
||
|
|
// spreading from all nodes, convergence is fast (O(log N) rounds).
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-100".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
assert!(
|
||
|
|
(metrics.delivery_ratio - 1.0).abs() < 1e-9,
|
||
|
|
"delivery_ratio = {}, expected 1.0",
|
||
|
|
metrics.delivery_ratio
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn delivery_is_all_or_nothing_per_key() {
|
||
|
|
// Full-mesh converges fast — O(log N). After convergence, each key is
|
||
|
|
// held by all nodes (atomic delivery).
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "atomic-fullmesh-100".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(4),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
assert!(
|
||
|
|
metrics.atomic_delivery,
|
||
|
|
"atomic_delivery should be true"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Latency (3) ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ring_converges_within_bound() {
|
||
|
|
// Ring with N=1000 should converge within N rounds.
|
||
|
|
let n = 1000;
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "ring-latency".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: n, // give it N rounds
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_convergence_bound(&metrics, n);
|
||
|
|
assert!(result.passed, "ring convergence: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn fullmesh_converges_in_log_n_rounds() {
|
||
|
|
// Full-mesh: all nodes spread in parallel, O(log N) convergence.
|
||
|
|
let n = 100;
|
||
|
|
let bound = 4 * ((n as f64).ln().ceil() as usize); // ≈ 20
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-latency".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_convergence_bound(&metrics, bound);
|
||
|
|
assert!(result.passed, "fullmesh convergence: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn last_node_latency_bounded_in_fullmesh() {
|
||
|
|
// In full-mesh, last node converges close to overall convergence.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-last-node".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_last_node_latency(&metrics, 5);
|
||
|
|
assert!(result.passed, "last node latency: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Message Complexity (3) ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn total_messages_equal_n_times_rounds() {
|
||
|
|
let n = 1000;
|
||
|
|
let r = 30;
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "msg-count".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: r,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
// In a ring, every node has exactly 1 peer, so each node sends exactly 1 push per round.
|
||
|
|
let expected = n * r;
|
||
|
|
let result = check_total_pushes_eq(&metrics, expected);
|
||
|
|
assert!(result.passed, "total_pushes: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn redundancy_increases_after_convergence() {
|
||
|
|
// Full-mesh 100 nodes: converges in ~10 rounds, run 50 → lots of redundant pushes.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "redundancy-fullmesh".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 50,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_redundancy_above(&metrics, 0.3);
|
||
|
|
assert!(result.passed, "redundancy: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn chain_has_minimal_waste() {
|
||
|
|
// Chain topology: data flows one direction, minimal redundancy until convergence.
|
||
|
|
// Compare chain's redundancy ratio to a denser topology's.
|
||
|
|
let n = 100;
|
||
|
|
let rounds = 120;
|
||
|
|
|
||
|
|
let chain_config = SimConfig {
|
||
|
|
name: "chain-waste".into(),
|
||
|
|
topology: Topology::Chain,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(1),
|
||
|
|
num_rounds: rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, chain_metrics) = run_and_analyze(chain_config);
|
||
|
|
|
||
|
|
let fullmesh_config = SimConfig {
|
||
|
|
name: "fullmesh-waste".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(1),
|
||
|
|
num_rounds: rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, fullmesh_metrics) = run_and_analyze(fullmesh_config);
|
||
|
|
|
||
|
|
// Chain should have lower redundancy ratio than full-mesh.
|
||
|
|
assert!(
|
||
|
|
chain_metrics.redundancy_ratio < fullmesh_metrics.redundancy_ratio,
|
||
|
|
"chain redundancy ({:.3}) should be less than fullmesh ({:.3})",
|
||
|
|
chain_metrics.redundancy_ratio,
|
||
|
|
fullmesh_metrics.redundancy_ratio
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Bandwidth/Load (3) ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn star_hub_is_hotspot() {
|
||
|
|
// Star with 100 nodes, 30 rounds: node-0 receives pushes from all leaves.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "star-hub".into(),
|
||
|
|
topology: Topology::Star,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_hub_is_hotspot(&metrics, "node-0");
|
||
|
|
assert!(result.passed, "hub hotspot: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ring_distributes_load_evenly() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "ring-load".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: 1000,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 60,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_load_balance_cv(&metrics, 0.3);
|
||
|
|
assert!(result.passed, "load CV: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn amplification_equals_num_rounds() {
|
||
|
|
let n = 1000;
|
||
|
|
let r = 30;
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "ring-amp".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: r,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_amplification(&metrics, r as f64, 1.0);
|
||
|
|
assert!(result.passed, "amplification: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Convergence (3) ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn convergence_curve_is_monotonic() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "ring-mono".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: 1000,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 60,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_curve_monotonic(&metrics);
|
||
|
|
assert!(result.passed, "monotonic: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn convergence_curve_has_s_shape() {
|
||
|
|
// Full-mesh 100 nodes: starts at 0, ramps up quickly, reaches 1.0 → S-shaped.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-s-shape".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_curve_s_shape(&metrics);
|
||
|
|
assert!(result.passed, "s-shape: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn zero_residue_after_sufficient_rounds() {
|
||
|
|
// FullMesh 100 converges in ~O(log N) rounds; 30 rounds is plenty.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-residue".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_zero_residue(&metrics);
|
||
|
|
assert!(result.passed, "residue: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Fault Tolerance (3) ─────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn partitioned_network_does_not_converge() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "partition-no-heal".into(),
|
||
|
|
topology: Topology::Partitioned,
|
||
|
|
num_nodes: 1000,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 40,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_partition_no_converge(&metrics);
|
||
|
|
assert!(result.passed, "partition no converge: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn partition_heals_and_converges() {
|
||
|
|
// Partitioned 100 = two halves of 50 nodes, each full-mesh internally.
|
||
|
|
// Each half converges in O(50*ln(50)) ~ 200 rounds. Heal at round 100,
|
||
|
|
// run 300 total to allow full convergence after healing.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "partition-heal".into(),
|
||
|
|
topology: Topology::Partitioned,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 300,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: Some(100),
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_partition_heals(&metrics);
|
||
|
|
assert!(result.passed, "partition heals: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn partial_convergence_before_healing() {
|
||
|
|
// Partitioned 100 = two halves of 50 nodes, each full-mesh internally.
|
||
|
|
// Each half converges in O(50*ln(50)) ~ 200 rounds. Heal at round 100,
|
||
|
|
// run 300 total to allow full convergence after healing.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "partition-partial".into(),
|
||
|
|
topology: Topology::Partitioned,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 300,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: Some(100),
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_partial_before_heal(&metrics, 100);
|
||
|
|
assert!(result.passed, "partial before heal: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Scalability (2) ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn convergence_time_scales_sublinearly() {
|
||
|
|
// FullMesh convergence is O(log N), which IS sublinear.
|
||
|
|
// Ring convergence is O(N), which is linear -- not suitable for this test.
|
||
|
|
let sizes = [100, 250, 500, 1000];
|
||
|
|
let mut data = Vec::new();
|
||
|
|
for &n in &sizes {
|
||
|
|
let rounds = 60; // O(log N) means even 1000 nodes converges in ~30 rounds
|
||
|
|
let config = SimConfig {
|
||
|
|
name: format!("scale-{n}"),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let cr = metrics.convergence_round.unwrap_or(rounds);
|
||
|
|
data.push((n, cr));
|
||
|
|
}
|
||
|
|
let result = check_sublinear_scaling(&data);
|
||
|
|
assert!(result.passed, "sublinear scaling: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn total_messages_scale_linearly_with_n() {
|
||
|
|
let sizes = [100, 250, 500, 1000];
|
||
|
|
let fixed_rounds = 30;
|
||
|
|
let mut data = Vec::new();
|
||
|
|
for &n in &sizes {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: format!("msg-scale-{n}"),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: fixed_rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
data.push((n, metrics.total_pushes));
|
||
|
|
}
|
||
|
|
let result = check_linear_message_scaling(&data, fixed_rounds);
|
||
|
|
assert!(result.passed, "linear message scaling: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Push Protocol (2) ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn one_push_per_node_per_round() {
|
||
|
|
let n = 500;
|
||
|
|
let r = 10;
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "push-protocol".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: r,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_one_push_per_node_per_round(&metrics, r);
|
||
|
|
assert!(result.passed, "one push per round: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn no_push_without_peers() {
|
||
|
|
let n = 100;
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "no-push-chain".into(),
|
||
|
|
topology: Topology::Chain,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(1),
|
||
|
|
num_rounds: 20,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (trace, _) = run_and_analyze(config);
|
||
|
|
// Last node in chain has no peers.
|
||
|
|
let last_node = format!("node-{}", n - 1);
|
||
|
|
let result = check_no_push_without_peers(&trace, &last_node);
|
||
|
|
assert!(result.passed, "no push without peers: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Peer Selection (1) ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn peer_selection_is_approximately_uniform() {
|
||
|
|
// Ring with 10 nodes: each node has 1 peer (the next in ring).
|
||
|
|
// With only 1 peer, chi-squared is trivially 0 (always picks the same).
|
||
|
|
// Use a wider ring: give each node 2 peers (bidirectional ring).
|
||
|
|
// Actually, ring topology only adds 1 peer (next). We need a small full-mesh or star.
|
||
|
|
// Use a star with 10 nodes: node-0 has 9 peers (nodes 1-9).
|
||
|
|
// Over 500 rounds, node-0 should select each peer ~55 times.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "peer-selection".into(),
|
||
|
|
topology: Topology::Star,
|
||
|
|
num_nodes: 10,
|
||
|
|
initial_data: test_data(1),
|
||
|
|
num_rounds: 500,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
// Chi-squared critical value for df=8 (9 peers - 1), p=0.001 is ~26.12.
|
||
|
|
let result = check_peer_selection_uniform(&metrics, 26.12);
|
||
|
|
assert!(result.passed, "peer selection: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Topology Impact (2) ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn denser_topology_converges_faster() {
|
||
|
|
let n = 100;
|
||
|
|
let keys = 5;
|
||
|
|
let rounds = 120; // enough for chain
|
||
|
|
|
||
|
|
let topologies = vec![
|
||
|
|
("FullMesh", Topology::FullMesh),
|
||
|
|
("Star", Topology::Star),
|
||
|
|
("Ring", Topology::Ring),
|
||
|
|
("Chain", Topology::Chain),
|
||
|
|
];
|
||
|
|
|
||
|
|
let mut convergence_times = Vec::new();
|
||
|
|
for (name, topo) in &topologies {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: format!("topo-{name}"),
|
||
|
|
topology: topo.clone(),
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(keys),
|
||
|
|
num_rounds: rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
convergence_times.push((*name, metrics.convergence_round.unwrap_or(rounds + 1)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// FullMesh should be fastest (smallest convergence round).
|
||
|
|
let fullmesh_time = convergence_times
|
||
|
|
.iter()
|
||
|
|
.find(|(n, _)| *n == "FullMesh")
|
||
|
|
.unwrap()
|
||
|
|
.1;
|
||
|
|
let chain_time = convergence_times
|
||
|
|
.iter()
|
||
|
|
.find(|(n, _)| *n == "Chain")
|
||
|
|
.unwrap()
|
||
|
|
.1;
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
fullmesh_time < chain_time,
|
||
|
|
"FullMesh ({}) should converge before Chain ({})",
|
||
|
|
fullmesh_time,
|
||
|
|
chain_time
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sparser_topology_is_more_efficient() {
|
||
|
|
let n = 100;
|
||
|
|
let keys = 5;
|
||
|
|
let rounds = 120;
|
||
|
|
|
||
|
|
let topologies = vec![
|
||
|
|
("FullMesh", Topology::FullMesh),
|
||
|
|
("Ring", Topology::Ring),
|
||
|
|
("Chain", Topology::Chain),
|
||
|
|
];
|
||
|
|
|
||
|
|
let mut redundancy_ratios = Vec::new();
|
||
|
|
for (name, topo) in &topologies {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: format!("eff-{name}"),
|
||
|
|
topology: topo.clone(),
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(keys),
|
||
|
|
num_rounds: rounds,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
redundancy_ratios.push((*name, metrics.redundancy_ratio));
|
||
|
|
}
|
||
|
|
|
||
|
|
let fullmesh_r = redundancy_ratios
|
||
|
|
.iter()
|
||
|
|
.find(|(n, _)| *n == "FullMesh")
|
||
|
|
.unwrap()
|
||
|
|
.1;
|
||
|
|
let chain_r = redundancy_ratios
|
||
|
|
.iter()
|
||
|
|
.find(|(n, _)| *n == "Chain")
|
||
|
|
.unwrap()
|
||
|
|
.1;
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
chain_r < fullmesh_r,
|
||
|
|
"Chain redundancy ({:.3}) should be lower than FullMesh ({:.3})",
|
||
|
|
chain_r,
|
||
|
|
fullmesh_r
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Consistency (4) ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn lww_ensures_single_final_value() {
|
||
|
|
// Full-mesh 100 nodes, converges fast → all keys single final value.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "lww-fullmesh".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_lww_single_value(&metrics);
|
||
|
|
assert!(result.passed, "lww single value: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn entropy_reaches_zero_at_convergence() {
|
||
|
|
// FullMesh 100 converges in ~O(log N) rounds; 30 rounds is plenty.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "entropy-fullmesh".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_entropy_zero_at_convergence(&metrics);
|
||
|
|
assert!(result.passed, "entropy zero: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn entropy_decreases_monotonically() {
|
||
|
|
// Entropy (disagreeing node-pairs) can increase before converging: with epidemic
|
||
|
|
// spreading, disagreements grow until ~50% have data, then shrink. Monotonic
|
||
|
|
// decrease is not achievable for any topology with gradual spreading.
|
||
|
|
// Instead, verify: (1) entropy reaches 0, (2) last 5 rounds all have entropy 0.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "entropy-convergence".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let tail = &metrics.entropy_per_round[metrics.entropy_per_round.len().saturating_sub(5)..];
|
||
|
|
let all_zero = tail.iter().all(|&e| e == 0);
|
||
|
|
assert!(
|
||
|
|
all_zero,
|
||
|
|
"entropy should be 0 for last 5 rounds, got: {:?}",
|
||
|
|
tail
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn no_stale_reads_after_convergence() {
|
||
|
|
// FullMesh 100 converges in ~O(log N) rounds; 30 rounds is plenty.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "no-stale".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_no_stale_reads(&metrics);
|
||
|
|
assert!(result.passed, "no stale reads: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Practical (2) ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn state_size_stabilizes_at_key_count() {
|
||
|
|
// FullMesh 100 converges in ~O(log N) rounds; 30 rounds is plenty for
|
||
|
|
// all 100 nodes to have all 5 keys.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "state-size-fullmesh".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_state_size_stabilizes(&metrics, 5.0);
|
||
|
|
assert!(result.passed, "state size: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn state_size_grows_monotonically() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "state-mono".into(),
|
||
|
|
topology: Topology::Ring,
|
||
|
|
num_nodes: 1000,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 60,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 1,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_state_size_monotonic(&metrics);
|
||
|
|
assert!(result.passed, "state size monotonic: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Multi-threaded variants (5) ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn all_nodes_receive_all_keys_in_ring_1000_mt() {
|
||
|
|
// FullMesh 100 nodes converges in ~O(log N) rounds, well within 30 rounds.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-100-mt".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 4,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
assert!(
|
||
|
|
(metrics.delivery_ratio - 1.0).abs() < 1e-9,
|
||
|
|
"MT delivery_ratio = {}, expected 1.0",
|
||
|
|
metrics.delivery_ratio
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn fullmesh_converges_in_log_n_rounds_mt() {
|
||
|
|
let n = 100;
|
||
|
|
// 2x bound for multi-threaded non-determinism.
|
||
|
|
let bound = 2 * 4 * ((n as f64).ln().ceil() as usize);
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-latency-mt".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: n,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 4,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_convergence_bound(&metrics, bound);
|
||
|
|
assert!(result.passed, "MT fullmesh convergence: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn convergence_curve_is_monotonic_mt() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "fullmesh-mono-mt".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 4,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_curve_monotonic(&metrics);
|
||
|
|
assert!(result.passed, "MT monotonic: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn partition_heals_and_converges_mt() {
|
||
|
|
// Partitioned 100 = two halves of 50 nodes, each full-mesh internally.
|
||
|
|
// Heal at round 100, run 300 total to allow full convergence after healing.
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "partition-heal-mt".into(),
|
||
|
|
topology: Topology::Partitioned,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 300,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: Some(100),
|
||
|
|
num_threads: 4,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_partition_heals(&metrics);
|
||
|
|
assert!(result.passed, "MT partition heals: {}", result.actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn lww_ensures_single_final_value_mt() {
|
||
|
|
let config = SimConfig {
|
||
|
|
name: "lww-fullmesh-mt".into(),
|
||
|
|
topology: Topology::FullMesh,
|
||
|
|
num_nodes: 100,
|
||
|
|
initial_data: test_data(5),
|
||
|
|
num_rounds: 30,
|
||
|
|
ticks_per_round: 4,
|
||
|
|
heal_after_round: None,
|
||
|
|
num_threads: 4,
|
||
|
|
};
|
||
|
|
let (_, metrics) = run_and_analyze(config);
|
||
|
|
let result = check_lww_single_value(&metrics);
|
||
|
|
assert!(result.passed, "MT lww single value: {}", result.actual);
|
||
|
|
}
|