swactor/crates/swactor-gossip/src/properties.rs
zacheryasc 9beca6c5dc feat: gossip simulation (#21)
Simulate a simple push-pull epidemic broadcast.
2026-02-08 16:18:39 +00:00

936 lines
31 KiB
Rust

use std::collections::HashMap;
use crate::sim::{SimConfig, Topology};
use crate::trace::{GossipEventKind, SimulationTrace};
// ── Metrics ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct GossipMetrics {
/// Fraction of nodes holding all keys at end.
pub delivery_ratio: f64,
/// Per-key: all nodes have it or none do.
pub atomic_delivery: bool,
/// First round where all nodes hold all keys.
pub convergence_round: Option<usize>,
/// Round the final node got all keys.
pub last_node_round: Option<usize>,
/// Count of GossipRoundStarted events (pushes sent).
pub total_pushes: usize,
/// Count of PushReceived with keys_updated == 0.
pub redundant_pushes: usize,
/// redundant / total.
pub redundancy_ratio: f64,
/// Push-sends per node.
pub pushes_sent_per_node: HashMap<String, usize>,
/// Push-receives per node.
pub pushes_received_per_node: HashMap<String, usize>,
/// Coefficient of variation of per-node receive load.
pub load_balance_cv: f64,
/// total_pushes / num_nodes.
pub amplification_factor: f64,
/// Per-round fraction of converged nodes.
pub convergence_curve: Vec<f64>,
/// 1.0 - curve[last].
pub residue: f64,
/// Per-node target selection histogram.
pub peer_selection_distribution: HashMap<String, HashMap<String, usize>>,
/// Disagreeing node-pairs per round.
pub entropy_per_round: Vec<usize>,
/// Distinct (value, version) tuples per key at end.
pub final_value_divergence: HashMap<String, usize>,
/// Mean entries per node per round.
pub avg_state_size_per_round: Vec<f64>,
pub num_nodes: usize,
pub num_edges: usize,
pub num_rounds: usize,
pub total_keys: usize,
}
// ── Analysis ────────────────────────────────────────────────────────────────
pub fn analyze(trace: &SimulationTrace) -> GossipMetrics {
let num_nodes = trace.node_names.len();
let num_edges = trace.topology_edges.len();
let num_rounds = trace.num_rounds;
let total_keys = trace.total_keys;
// ── Pass 1: events ──────────────────────────────────────────────────
let mut total_pushes = 0usize;
let mut redundant_pushes = 0usize;
let mut pushes_sent: HashMap<String, usize> = HashMap::new();
let mut pushes_received: HashMap<String, usize> = HashMap::new();
let mut peer_selection: HashMap<String, HashMap<String, usize>> = HashMap::new();
for event in &trace.events {
match &event.kind {
GossipEventKind::GossipRoundStarted { target_name } => {
total_pushes += 1;
*pushes_sent.entry(event.node_name.clone()).or_default() += 1;
*peer_selection
.entry(event.node_name.clone())
.or_default()
.entry(target_name.clone())
.or_default() += 1;
}
GossipEventKind::PushReceived { keys_updated, .. } => {
*pushes_received
.entry(event.node_name.clone())
.or_default() += 1;
if *keys_updated == 0 {
redundant_pushes += 1;
}
}
_ => {}
}
}
let redundancy_ratio = if total_pushes > 0 {
redundant_pushes as f64 / total_pushes as f64
} else {
0.0
};
// ── Pass 2: snapshots ───────────────────────────────────────────────
let mut convergence_curve = Vec::with_capacity(num_rounds);
let mut entropy_per_round = Vec::with_capacity(num_rounds);
let mut avg_state_size_per_round = Vec::with_capacity(num_rounds);
let mut convergence_round: Option<usize> = None;
let mut last_node_round: Option<usize> = None;
// Track per-node first convergence round.
let mut node_converged_at: HashMap<String, usize> = HashMap::new();
for (round_idx, round_snaps) in trace.snapshots_per_round.iter().enumerate() {
let round_num = round_idx + 1;
// Convergence fraction.
let converged_count = if total_keys > 0 {
round_snaps
.iter()
.filter(|(_, snap)| snap.entries.len() >= total_keys)
.count()
} else {
num_nodes
};
let frac = if num_nodes > 0 {
converged_count as f64 / num_nodes as f64
} else {
1.0
};
convergence_curve.push(frac);
if convergence_round.is_none() && converged_count == num_nodes {
convergence_round = Some(round_num);
}
// Track per-node convergence.
for (name, snap) in round_snaps {
if total_keys > 0 && snap.entries.len() >= total_keys {
node_converged_at.entry(name.clone()).or_insert(round_num);
}
}
// Entropy: count disagreeing node-pairs.
// For large N, use majority-deviation approach.
let entropy = if num_nodes <= 1500 {
compute_entropy_pairwise(round_snaps, total_keys)
} else {
compute_entropy_majority(round_snaps, total_keys)
};
entropy_per_round.push(entropy);
// Average state size.
let total_entries: usize = round_snaps.iter().map(|(_, s)| s.entries.len()).sum();
let avg = if round_snaps.is_empty() {
0.0
} else {
total_entries as f64 / round_snaps.len() as f64
};
avg_state_size_per_round.push(avg);
}
// Last node round.
if !node_converged_at.is_empty() {
last_node_round = node_converged_at.values().max().copied();
}
// Delivery ratio from final round.
let delivery_ratio = convergence_curve.last().copied().unwrap_or(0.0);
// Atomic delivery: per-key, either all nodes have it or none do.
let atomic_delivery = check_atomic_delivery_inner(trace);
// Final value divergence.
let final_value_divergence = compute_final_divergence(trace);
// Load balance CV.
let recv_counts: Vec<f64> = trace
.node_names
.iter()
.map(|n| *pushes_received.get(n).unwrap_or(&0) as f64)
.collect();
let load_balance_cv = coeff_of_variation(&recv_counts);
let amplification_factor = if num_nodes > 0 {
total_pushes as f64 / num_nodes as f64
} else {
0.0
};
let residue = 1.0 - convergence_curve.last().copied().unwrap_or(0.0);
GossipMetrics {
delivery_ratio,
atomic_delivery,
convergence_round,
last_node_round,
total_pushes,
redundant_pushes,
redundancy_ratio,
pushes_sent_per_node: pushes_sent,
pushes_received_per_node: pushes_received,
load_balance_cv,
amplification_factor,
convergence_curve,
residue,
peer_selection_distribution: peer_selection,
entropy_per_round,
final_value_divergence,
avg_state_size_per_round,
num_nodes,
num_edges,
num_rounds,
total_keys,
}
}
// ── Entropy helpers ─────────────────────────────────────────────────────────
fn compute_entropy_pairwise(
round_snaps: &[(String, crate::trace::NodeSnapshot)],
total_keys: usize,
) -> usize {
if total_keys == 0 {
return 0;
}
let mut disagreements = 0usize;
for i in 0..round_snaps.len() {
for j in (i + 1)..round_snaps.len() {
let (_, snap_i) = &round_snaps[i];
let (_, snap_j) = &round_snaps[j];
if snap_i.entries.len() != snap_j.entries.len() {
disagreements += 1;
continue;
}
let mut agree = true;
for (key, val_i) in &snap_i.entries {
match snap_j.entries.get(key) {
Some(val_j) if val_j.version == val_i.version => {}
_ => {
agree = false;
break;
}
}
}
if !agree {
disagreements += 1;
}
}
}
disagreements
}
fn compute_entropy_majority(
round_snaps: &[(String, crate::trace::NodeSnapshot)],
total_keys: usize,
) -> usize {
if total_keys == 0 || round_snaps.is_empty() {
return 0;
}
// For each key, find majority (value, version), count deviators.
let mut deviating_nodes = std::collections::HashSet::new();
// Collect all keys seen.
let mut all_keys = std::collections::HashSet::new();
for (_, snap) in round_snaps {
for key in snap.entries.keys() {
all_keys.insert(key.clone());
}
}
for key in &all_keys {
// Count occurrences of each version.
let mut version_counts: HashMap<u64, usize> = HashMap::new();
let mut missing_count = 0usize;
for (_, snap) in round_snaps {
match snap.entries.get(key) {
Some(val) => *version_counts.entry(val.version).or_default() += 1,
None => missing_count += 1,
}
}
// Majority version.
let majority_version = version_counts
.iter()
.max_by_key(|(_, c)| *c)
.map(|(&v, _)| v);
if let Some(mv) = majority_version {
for (i, (_, snap)) in round_snaps.iter().enumerate() {
match snap.entries.get(key) {
Some(val) if val.version == mv => {}
_ => {
deviating_nodes.insert(i);
}
}
}
}
if missing_count > 0 {
for (i, (_, snap)) in round_snaps.iter().enumerate() {
if !snap.entries.contains_key(key) {
deviating_nodes.insert(i);
}
}
}
}
// Approximate pair count: each deviating node forms pairs with all non-deviating.
let d = deviating_nodes.len();
let n = round_snaps.len();
let agreeing = n - d;
// Pairs: d * agreeing + d*(d-1)/2
d * agreeing + d * d.saturating_sub(1) / 2
}
fn check_atomic_delivery_inner(trace: &SimulationTrace) -> bool {
if let Some(last_round) = trace.snapshots_per_round.last() {
// Collect all keys seen across all nodes.
let mut all_keys = std::collections::HashSet::new();
for (_, snap) in last_round {
for key in snap.entries.keys() {
all_keys.insert(key.clone());
}
}
// For each key: either all nodes have it or none do.
for key in &all_keys {
let has_it = last_round
.iter()
.filter(|(_, snap)| snap.entries.contains_key(key))
.count();
if has_it != 0 && has_it != last_round.len() {
return false;
}
}
true
} else {
true
}
}
fn compute_final_divergence(trace: &SimulationTrace) -> HashMap<String, usize> {
let mut divergence = HashMap::new();
if let Some(last_round) = trace.snapshots_per_round.last() {
let mut all_keys = std::collections::HashSet::new();
for (_, snap) in last_round {
for key in snap.entries.keys() {
all_keys.insert(key.clone());
}
}
for key in &all_keys {
let mut distinct = std::collections::HashSet::new();
for (_, snap) in last_round {
if let Some(val) = snap.entries.get(key) {
distinct.insert((val.value.clone(), val.version));
}
}
divergence.insert(key.clone(), distinct.len());
}
}
divergence
}
// ── Property results ────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct PropertyResult {
pub name: String,
pub category: String,
pub passed: bool,
pub expected: String,
pub actual: String,
pub description: String,
}
// ── Property check functions ────────────────────────────────────────────────
pub fn check_delivery_ratio(metrics: &GossipMetrics, expected: f64) -> PropertyResult {
PropertyResult {
name: "delivery_ratio".into(),
category: "Reliability".into(),
passed: (metrics.delivery_ratio - expected).abs() < 1e-9,
expected: format!("{expected}"),
actual: format!("{}", metrics.delivery_ratio),
description: "Fraction of nodes holding all keys at end".into(),
}
}
pub fn check_atomic_delivery(metrics: &GossipMetrics) -> PropertyResult {
PropertyResult {
name: "atomic_delivery".into(),
category: "Reliability".into(),
passed: metrics.atomic_delivery,
expected: "true".into(),
actual: format!("{}", metrics.atomic_delivery),
description: "Per-key: all nodes have it or none do".into(),
}
}
pub fn check_convergence_bound(
metrics: &GossipMetrics,
max_rounds: usize,
) -> PropertyResult {
let passed = metrics
.convergence_round
.map(|r| r <= max_rounds)
.unwrap_or(false);
PropertyResult {
name: "convergence_bound".into(),
category: "Latency".into(),
passed,
expected: format!("≤ {max_rounds}"),
actual: metrics
.convergence_round
.map(|r| r.to_string())
.unwrap_or("never".into()),
description: "Convergence within expected round bound".into(),
}
}
pub fn check_last_node_latency(
metrics: &GossipMetrics,
max_gap: usize,
) -> PropertyResult {
let passed = match (metrics.convergence_round, metrics.last_node_round) {
(Some(c), Some(l)) => l.abs_diff(c) <= max_gap,
_ => false,
};
PropertyResult {
name: "last_node_latency".into(),
category: "Latency".into(),
passed,
expected: format!("gap ≤ {max_gap}"),
actual: format!(
"convergence={}, last_node={}",
metrics
.convergence_round
.map(|r| r.to_string())
.unwrap_or("none".into()),
metrics
.last_node_round
.map(|r| r.to_string())
.unwrap_or("none".into())
),
description: "Last node converges close to overall convergence".into(),
}
}
pub fn check_total_pushes_eq(
metrics: &GossipMetrics,
expected: usize,
) -> PropertyResult {
PropertyResult {
name: "total_pushes".into(),
category: "Message Complexity".into(),
passed: metrics.total_pushes == expected,
expected: format!("{expected}"),
actual: format!("{}", metrics.total_pushes),
description: "Total push messages equals expected count".into(),
}
}
pub fn check_redundancy_above(
metrics: &GossipMetrics,
min_ratio: f64,
) -> PropertyResult {
PropertyResult {
name: "redundancy_ratio".into(),
category: "Message Complexity".into(),
passed: metrics.redundancy_ratio > min_ratio,
expected: format!("> {min_ratio}"),
actual: format!("{:.3}", metrics.redundancy_ratio),
description: "Redundancy ratio exceeds threshold".into(),
}
}
pub fn check_hub_is_hotspot(
metrics: &GossipMetrics,
hub_name: &str,
) -> PropertyResult {
let hub_recv = *metrics.pushes_received_per_node.get(hub_name).unwrap_or(&0);
let max_recv = metrics
.pushes_received_per_node
.values()
.max()
.copied()
.unwrap_or(0);
PropertyResult {
name: "hub_hotspot".into(),
category: "Bandwidth/Load".into(),
passed: hub_recv == max_recv && hub_recv > 0,
expected: format!("{hub_name} receives most"),
actual: format!("{hub_name} received {hub_recv}, max was {max_recv}"),
description: "Star hub receives the most pushes".into(),
}
}
pub fn check_load_balance_cv(
metrics: &GossipMetrics,
max_cv: f64,
) -> PropertyResult {
PropertyResult {
name: "load_balance_cv".into(),
category: "Bandwidth/Load".into(),
passed: metrics.load_balance_cv < max_cv,
expected: format!("< {max_cv}"),
actual: format!("{:.4}", metrics.load_balance_cv),
description: "Load balance coefficient of variation".into(),
}
}
pub fn check_amplification(
metrics: &GossipMetrics,
expected_approx: f64,
tolerance: f64,
) -> PropertyResult {
let diff = (metrics.amplification_factor - expected_approx).abs();
PropertyResult {
name: "amplification_factor".into(),
category: "Bandwidth/Load".into(),
passed: diff <= tolerance,
expected: format!("{expected_approx} ± {tolerance}"),
actual: format!("{:.2}", metrics.amplification_factor),
description: "Amplification factor (pushes / nodes)".into(),
}
}
pub fn check_curve_monotonic(metrics: &GossipMetrics) -> PropertyResult {
let mono = metrics
.convergence_curve
.windows(2)
.all(|w| w[1] >= w[0] - 1e-9);
PropertyResult {
name: "curve_monotonic".into(),
category: "Convergence".into(),
passed: mono,
expected: "monotonically non-decreasing".into(),
actual: if mono {
"monotonic".into()
} else {
"non-monotonic".into()
},
description: "Convergence curve never decreases".into(),
}
}
pub fn check_curve_s_shape(metrics: &GossipMetrics) -> PropertyResult {
let curve = &metrics.convergence_curve;
if curve.len() < 3 {
return PropertyResult {
name: "curve_s_shape".into(),
category: "Convergence".into(),
passed: false,
expected: "S-shaped curve".into(),
actual: "too few data points".into(),
description: "Convergence curve has S-shape".into(),
};
}
let starts_low = curve[0] < 0.5;
let ends_high = *curve.last().unwrap() >= 1.0 - 1e-9;
// Steep middle: at least one consecutive pair has > 0.1 jump.
let has_steep = curve.windows(2).any(|w| (w[1] - w[0]) > 0.05);
let passed = starts_low && ends_high && has_steep;
PropertyResult {
name: "curve_s_shape".into(),
category: "Convergence".into(),
passed,
expected: "starts < 0.5, ends ≥ 1.0, steep middle".into(),
actual: format!(
"start={:.2}, end={:.2}, steep={}",
curve[0],
curve.last().unwrap(),
has_steep
),
description: "Convergence curve has S-shape".into(),
}
}
pub fn check_zero_residue(metrics: &GossipMetrics) -> PropertyResult {
PropertyResult {
name: "zero_residue".into(),
category: "Convergence".into(),
passed: metrics.residue.abs() < 1e-9,
expected: "0.0".into(),
actual: format!("{:.6}", metrics.residue),
description: "All nodes converged (zero residue)".into(),
}
}
pub fn check_partition_no_converge(metrics: &GossipMetrics) -> PropertyResult {
PropertyResult {
name: "partition_no_converge".into(),
category: "Fault Tolerance".into(),
passed: metrics.delivery_ratio < 1.0,
expected: "< 1.0".into(),
actual: format!("{}", metrics.delivery_ratio),
description: "Partitioned network does not fully converge".into(),
}
}
pub fn check_partition_heals(metrics: &GossipMetrics) -> PropertyResult {
PropertyResult {
name: "partition_heals".into(),
category: "Fault Tolerance".into(),
passed: (metrics.delivery_ratio - 1.0).abs() < 1e-9,
expected: "1.0".into(),
actual: format!("{}", metrics.delivery_ratio),
description: "Healed partition reaches full convergence".into(),
}
}
pub fn check_partial_before_heal(
metrics: &GossipMetrics,
heal_round: usize,
) -> PropertyResult {
let before_heal = if heal_round > 0 && heal_round <= metrics.convergence_curve.len() {
metrics.convergence_curve[heal_round - 1]
} else {
1.0
};
let at_end = *metrics.convergence_curve.last().unwrap_or(&0.0);
let passed = before_heal < 1.0 && (at_end - 1.0).abs() < 1e-9;
PropertyResult {
name: "partial_before_heal".into(),
category: "Fault Tolerance".into(),
passed,
expected: "< 1.0 before heal, 1.0 after".into(),
actual: format!("before_heal={before_heal:.2}, end={at_end:.2}"),
description: "Partial convergence before healing, full after".into(),
}
}
pub fn check_sublinear_scaling(
convergence_times: &[(usize, usize)],
) -> PropertyResult {
// Check: doubling N does NOT double convergence time.
// Sort by N.
let mut sorted: Vec<(usize, usize)> = convergence_times.to_vec();
sorted.sort_by_key(|&(n, _)| n);
let passed = if sorted.len() >= 2 {
let mut all_sublinear = true;
for i in 1..sorted.len() {
let (n1, t1) = sorted[i - 1];
let (n2, t2) = sorted[i];
if n2 > n1 && t1 > 0 {
let n_ratio = n2 as f64 / n1 as f64;
let t_ratio = t2 as f64 / t1 as f64;
if t_ratio >= n_ratio {
all_sublinear = false;
break;
}
}
}
all_sublinear
} else {
false
};
PropertyResult {
name: "sublinear_scaling".into(),
category: "Scalability".into(),
passed,
expected: "convergence time scales sublinearly".into(),
actual: format!("{:?}", convergence_times),
description: "Doubling N does not double convergence time".into(),
}
}
pub fn check_linear_message_scaling(
pushes_per_n: &[(usize, usize)],
fixed_rounds: usize,
) -> PropertyResult {
// pushes/N should be approximately constant (= fixed_rounds).
let ratios: Vec<f64> = pushes_per_n
.iter()
.map(|&(n, p)| p as f64 / n as f64)
.collect();
let cv = coeff_of_variation(&ratios);
let passed = cv < 0.15; // low variation means roughly constant
PropertyResult {
name: "linear_message_scaling".into(),
category: "Scalability".into(),
passed,
expected: format!("pushes/N ≈ {fixed_rounds}, CV < 0.15"),
actual: format!("ratios={:?}, CV={cv:.4}", ratios),
description: "Total messages scale linearly with N".into(),
}
}
pub fn check_one_push_per_node_per_round(
metrics: &GossipMetrics,
num_rounds_checked: usize,
) -> PropertyResult {
let expected_total = metrics.num_nodes * num_rounds_checked;
// Nodes with no peers don't push, so count only GossipRoundStarted + GossipRoundNoPeers.
// Actually, total_pushes is only GossipRoundStarted. We need to count GossipRoundNoPeers too.
// Just check total_pushes + no_peers_count == N * R from the metrics data.
// We verify total_pushes == expected_total for nodes that have peers.
// For simplicity: total_pushes should be close to N * R (minus nodes without peers).
let passed = metrics.total_pushes <= expected_total;
PropertyResult {
name: "one_push_per_node_per_round".into(),
category: "Push Protocol".into(),
passed,
expected: format!("≤ {expected_total}"),
actual: format!("{}", metrics.total_pushes),
description: "At most one push per node per round".into(),
}
}
pub fn check_no_push_without_peers(
trace: &SimulationTrace,
node_name: &str,
) -> PropertyResult {
// The specified node should only emit GossipRoundNoPeers, never GossipRoundStarted.
let has_push = trace.events.iter().any(|e| {
e.node_name == node_name
&& matches!(e.kind, GossipEventKind::GossipRoundStarted { .. })
});
let has_no_peers = trace.events.iter().any(|e| {
e.node_name == node_name && matches!(e.kind, GossipEventKind::GossipRoundNoPeers)
});
PropertyResult {
name: "no_push_without_peers".into(),
category: "Push Protocol".into(),
passed: !has_push && has_no_peers,
expected: "only GossipRoundNoPeers".into(),
actual: format!("has_push={has_push}, has_no_peers={has_no_peers}"),
description: "Node without peers emits NoPeers, not Push".into(),
}
}
pub fn check_peer_selection_uniform(
metrics: &GossipMetrics,
chi_squared_critical: f64,
) -> PropertyResult {
// For each node, compute chi-squared against uniform distribution over peers.
let mut worst_chi2 = 0.0f64;
let mut worst_node = String::new();
for (node, targets) in &metrics.peer_selection_distribution {
if targets.is_empty() {
continue;
}
let counts: Vec<f64> = targets.values().map(|&c| c as f64).collect();
let chi2 = chi_squared_uniform(&counts);
if chi2 > worst_chi2 {
worst_chi2 = chi2;
worst_node = node.clone();
}
}
PropertyResult {
name: "peer_selection_uniform".into(),
category: "Peer Selection".into(),
passed: worst_chi2 < chi_squared_critical,
expected: format!("χ² < {chi_squared_critical}"),
actual: format!("worst χ²={worst_chi2:.2} at {worst_node}"),
description: "Peer selection approximately uniform (chi-squared)".into(),
}
}
pub fn check_lww_single_value(metrics: &GossipMetrics) -> PropertyResult {
let all_single = metrics
.final_value_divergence
.values()
.all(|&count| count == 1);
let details: Vec<String> = metrics
.final_value_divergence
.iter()
.filter(|(_, c)| **c != 1)
.map(|(k, c)| format!("{k}:{c}"))
.collect();
PropertyResult {
name: "lww_single_value".into(),
category: "Consistency".into(),
passed: all_single,
expected: "1 distinct value per key".into(),
actual: if all_single {
"all keys have 1 value".into()
} else {
format!("divergent: {:?}", details)
},
description: "LWW ensures single final value per key".into(),
}
}
pub fn check_entropy_zero_at_convergence(
metrics: &GossipMetrics,
) -> PropertyResult {
let passed = if let Some(cr) = metrics.convergence_round {
metrics
.entropy_per_round
.iter()
.skip(cr.saturating_sub(1))
.all(|&e| e == 0)
} else {
false
};
PropertyResult {
name: "entropy_zero_at_convergence".into(),
category: "Consistency".into(),
passed,
expected: "entropy = 0 after convergence".into(),
actual: format!(
"convergence_round={:?}, final_entropy={}",
metrics.convergence_round,
metrics.entropy_per_round.last().unwrap_or(&0)
),
description: "Entropy reaches zero at convergence".into(),
}
}
pub fn check_entropy_decreases(metrics: &GossipMetrics) -> PropertyResult {
let mono = metrics
.entropy_per_round
.windows(2)
.all(|w| w[1] <= w[0]);
PropertyResult {
name: "entropy_decreases".into(),
category: "Consistency".into(),
passed: mono,
expected: "monotonically non-increasing".into(),
actual: if mono {
"monotonic".into()
} else {
let violations: Vec<usize> = metrics
.entropy_per_round
.windows(2)
.enumerate()
.filter(|(_, w)| w[1] > w[0])
.map(|(i, _)| i + 1)
.collect();
format!("increases at rounds {:?}", violations)
},
description: "Entropy never increases".into(),
}
}
pub fn check_no_stale_reads(metrics: &GossipMetrics) -> PropertyResult {
// All keys have exactly 1 distinct value AND delivery_ratio == 1.0.
let all_single = metrics
.final_value_divergence
.values()
.all(|&c| c == 1);
let passed = all_single && (metrics.delivery_ratio - 1.0).abs() < 1e-9;
PropertyResult {
name: "no_stale_reads".into(),
category: "Consistency".into(),
passed,
expected: "all nodes agree post-convergence".into(),
actual: format!(
"delivery={}, all_single={}",
metrics.delivery_ratio, all_single
),
description: "No stale reads after convergence".into(),
}
}
pub fn check_state_size_stabilizes(
metrics: &GossipMetrics,
expected_final: f64,
) -> PropertyResult {
let final_avg = metrics.avg_state_size_per_round.last().copied().unwrap_or(0.0);
let passed = (final_avg - expected_final).abs() < 0.5;
PropertyResult {
name: "state_size_stabilizes".into(),
category: "Practical".into(),
passed,
expected: format!("{expected_final}"),
actual: format!("{final_avg:.2}"),
description: "Final average state size matches key count".into(),
}
}
pub fn check_state_size_monotonic(metrics: &GossipMetrics) -> PropertyResult {
let mono = metrics
.avg_state_size_per_round
.windows(2)
.all(|w| w[1] >= w[0] - 1e-9);
PropertyResult {
name: "state_size_monotonic".into(),
category: "Practical".into(),
passed: mono,
expected: "non-decreasing".into(),
actual: if mono {
"monotonic".into()
} else {
"non-monotonic".into()
},
description: "Average state size never decreases".into(),
}
}
// ── Helper: base SimConfig ──────────────────────────────────────────────────
pub fn base_config(
name: &str,
topology: Topology,
num_nodes: usize,
num_keys: usize,
) -> SimConfig {
SimConfig {
name: name.into(),
topology,
num_nodes,
initial_data: (0..num_keys)
.map(|i| (format!("key-{i}"), format!("value-{i}").into_bytes()))
.collect(),
num_rounds: 30,
ticks_per_round: 4,
heal_after_round: None,
num_threads: 1,
}
}
// ── Statistical helpers ─────────────────────────────────────────────────────
pub fn std_dev(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
variance.sqrt()
}
pub fn coeff_of_variation(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
if mean.abs() < 1e-12 {
return 0.0;
}
std_dev(values) / mean
}
pub fn chi_squared_uniform(observed: &[f64]) -> f64 {
if observed.is_empty() {
return 0.0;
}
let total: f64 = observed.iter().sum();
let expected = total / observed.len() as f64;
if expected.abs() < 1e-12 {
return 0.0;
}
observed
.iter()
.map(|&o| (o - expected).powi(2) / expected)
.sum()
}