Compare commits
3 commits
3a7f07aa8d
...
217db67338
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217db67338 | ||
|
|
77ba550fdc | ||
|
|
3f727e4743 |
35 changed files with 7669 additions and 313 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,3 +3,7 @@
|
||||||
.vscode/
|
.vscode/
|
||||||
**/.venv
|
**/.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
# Analysis artifacts (depgraph + spectral)
|
||||||
|
**/deps.dot
|
||||||
|
**/deps.html
|
||||||
|
|
|
||||||
141
Cargo.lock
generated
141
Cargo.lock
generated
|
|
@ -23,6 +23,12 @@ version = "1.0.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -47,6 +53,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -181,6 +193,12 @@ version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -192,6 +210,18 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gossip-dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"swactor",
|
||||||
|
"swactor-gossip",
|
||||||
|
"tiny_http",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
|
|
@ -203,6 +233,12 @@ dependencies = [
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -215,6 +251,22 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.7"
|
version = "2.0.7"
|
||||||
|
|
@ -266,6 +318,12 @@ version = "0.2.180"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
|
|
@ -524,6 +582,15 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swactor"
|
name = "swactor"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -532,6 +599,18 @@ dependencies = [
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swactor-gossip"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"swactor",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -567,6 +646,18 @@ version = "0.12.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinytemplate"
|
name = "tinytemplate"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -577,6 +668,47 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
|
|
@ -684,6 +816,15 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.39"
|
version = "0.8.39"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "crates/swactor-python", "crates/swactor-wasm"]
|
members = [".", "crates/swactor-python", "crates/swactor-wasm", "crates/swactor-gossip", "crates/gossip-dashboard"]
|
||||||
exclude = ["tools/depgraph"]
|
exclude = ["tools/depgraph"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
|
|
@ -14,10 +14,12 @@ crate-type = ["rlib"]
|
||||||
[features]
|
[features]
|
||||||
default = ["getrandom"]
|
default = ["getrandom"]
|
||||||
getrandom = ["dep:getrandom"]
|
getrandom = ["dep:getrandom"]
|
||||||
|
serde = ["dep:serde"]
|
||||||
no_random = [] # compile without access to a source of randomness
|
no_random = [] # compile without access to a source of randomness
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
getrandom = { version = "0.2", optional = true }
|
getrandom = { version = "0.2", optional = true }
|
||||||
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
crossbeam-queue = "0.3.12"
|
crossbeam-queue = "0.3.12"
|
||||||
crossbeam-utils = "0.8.21"
|
crossbeam-utils = "0.8.21"
|
||||||
|
|
||||||
|
|
|
||||||
12
crates/gossip-dashboard/Cargo.toml
Normal file
12
crates/gossip-dashboard/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "gossip-dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
swactor = { path = "../..", features = ["serde"] }
|
||||||
|
swactor-gossip = { path = "../swactor-gossip" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tiny_http = "0.12"
|
||||||
|
toml = "0.8"
|
||||||
40
crates/gossip-dashboard/examples/dashboard.rs
Normal file
40
crates/gossip-dashboard/examples/dashboard.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use gossip_dashboard::{DashboardConfig, config::SimFileConfig, run_with_dashboard, save_trace};
|
||||||
|
use swactor_gossip::sim::{SimConfig, Topology};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
let (config, dash) = if let Some(path) = args.get(1) {
|
||||||
|
let file_config = SimFileConfig::load(path).expect("failed to load config file");
|
||||||
|
file_config.into_sim_config()
|
||||||
|
} else {
|
||||||
|
let config = SimConfig {
|
||||||
|
name: "Ring-10 Demo".to_string(),
|
||||||
|
topology: Topology::Ring,
|
||||||
|
num_nodes: 10,
|
||||||
|
initial_data: vec![
|
||||||
|
("color".into(), b"blue".to_vec()),
|
||||||
|
("version".into(), b"1".to_vec()),
|
||||||
|
("status".into(), b"active".to_vec()),
|
||||||
|
],
|
||||||
|
num_rounds: 15,
|
||||||
|
ticks_per_round: 5,
|
||||||
|
heal_after_round: None,
|
||||||
|
num_threads: 2,
|
||||||
|
};
|
||||||
|
let dash = DashboardConfig { port: 8080 };
|
||||||
|
(config, dash)
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!("Starting gossip dashboard at http://localhost:{}", dash.port);
|
||||||
|
eprintln!("Open in your browser to see the simulation live.");
|
||||||
|
|
||||||
|
let trace = run_with_dashboard(config, dash);
|
||||||
|
|
||||||
|
let path = "demo.trace.json";
|
||||||
|
save_trace(&trace, path).expect("failed to save trace");
|
||||||
|
eprintln!("Trace saved to {path}");
|
||||||
|
eprintln!(
|
||||||
|
"Replay with: cargo run -p gossip-dashboard --example replay -- {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
19
crates/gossip-dashboard/examples/replay.rs
Normal file
19
crates/gossip-dashboard/examples/replay.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use gossip_dashboard::{load_trace, serve_replay};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let path = args
|
||||||
|
.get(1)
|
||||||
|
.expect("Usage: replay <trace.json>");
|
||||||
|
|
||||||
|
let trace = load_trace(path).expect("failed to load trace");
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Loaded trace '{}': {} nodes, {} events",
|
||||||
|
trace.name,
|
||||||
|
trace.node_names.len(),
|
||||||
|
trace.events.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
serve_replay(&trace, 8081);
|
||||||
|
}
|
||||||
12
crates/gossip-dashboard/examples/sim.toml
Normal file
12
crates/gossip-dashboard/examples/sim.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
name = "Partitioned-8 Heal"
|
||||||
|
topology = "partitioned"
|
||||||
|
num_nodes = 8
|
||||||
|
num_rounds = 20
|
||||||
|
ticks_per_round = 5
|
||||||
|
num_threads = 2
|
||||||
|
heal_after_round = 10
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[initial_data]
|
||||||
|
color = "blue"
|
||||||
|
version = "1"
|
||||||
63
crates/gossip-dashboard/src/config.rs
Normal file
63
crates/gossip-dashboard/src/config.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use swactor_gossip::sim::{SimConfig, Topology};
|
||||||
|
|
||||||
|
use crate::server::DashboardConfig;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SimFileConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub topology: String,
|
||||||
|
pub num_nodes: usize,
|
||||||
|
pub num_rounds: usize,
|
||||||
|
pub ticks_per_round: usize,
|
||||||
|
pub num_threads: usize,
|
||||||
|
pub heal_after_round: Option<usize>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub initial_data: Option<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimFileConfig {
|
||||||
|
pub fn load(path: &str) -> io::Result<Self> {
|
||||||
|
let contents = fs::read_to_string(path)?;
|
||||||
|
toml::from_str(&contents).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_sim_config(self) -> (SimConfig, DashboardConfig) {
|
||||||
|
let topology = match self.topology.to_lowercase().as_str() {
|
||||||
|
"ring" => Topology::Ring,
|
||||||
|
"star" => Topology::Star,
|
||||||
|
"full_mesh" => Topology::FullMesh,
|
||||||
|
"chain" => Topology::Chain,
|
||||||
|
"partitioned" => Topology::Partitioned,
|
||||||
|
other => panic!("unknown topology: {other:?} (expected ring, star, full_mesh, chain, or partitioned)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let initial_data = self
|
||||||
|
.initial_data
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, v.into_bytes()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sim = SimConfig {
|
||||||
|
name: self.name,
|
||||||
|
topology,
|
||||||
|
num_nodes: self.num_nodes,
|
||||||
|
initial_data,
|
||||||
|
num_rounds: self.num_rounds,
|
||||||
|
ticks_per_round: self.ticks_per_round,
|
||||||
|
heal_after_round: self.heal_after_round,
|
||||||
|
num_threads: self.num_threads,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dash = DashboardConfig {
|
||||||
|
port: self.port.unwrap_or(8080),
|
||||||
|
};
|
||||||
|
|
||||||
|
(sim, dash)
|
||||||
|
}
|
||||||
|
}
|
||||||
616
crates/gossip-dashboard/src/dashboard_html.rs
Normal file
616
crates/gossip-dashboard/src/dashboard_html.rs
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gossip Simulation Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #0f1117; color: #e0e0e0; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3a;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 18px; font-weight: 600; color: #c0c6d4; }
|
||||||
|
.status-badge {
|
||||||
|
display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6; }
|
||||||
|
.status-dot.done { background: #22c55e; }
|
||||||
|
.status-dot.replay { background: #f59e0b; }
|
||||||
|
|
||||||
|
.main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; height: calc(100vh - 48px); }
|
||||||
|
|
||||||
|
.graph-panel {
|
||||||
|
grid-row: 1 / 3; border-right: 1px solid #2a2d3a; position: relative;
|
||||||
|
}
|
||||||
|
canvas { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid #2a2d3a; background: #161822;
|
||||||
|
}
|
||||||
|
.stats-panel h2 { font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||||
|
.stat-item .stat-value { font-size: 22px; font-weight: 700; color: #e5e7eb; }
|
||||||
|
.stat-item .stat-label { font-size: 11px; color: #6b7280; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.worker-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid #2a2d3a; height: 180px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.worker-panel h2 { font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||||
|
.worker-columns { display: flex; gap: 8px; overflow-x: auto; }
|
||||||
|
.worker-col {
|
||||||
|
flex: 1; min-width: 160px; background: #1a1d2e; border-radius: 6px; padding: 8px;
|
||||||
|
font-size: 11px; max-height: 140px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.worker-col-header { font-weight: 600; color: #818cf8; margin-bottom: 4px; font-size: 12px; }
|
||||||
|
.worker-entry { color: #9ca3af; padding: 1px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.event-panel {
|
||||||
|
flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.event-panel h2 {
|
||||||
|
font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
}
|
||||||
|
.event-table-wrap { flex: 1; overflow-y: auto; padding: 0 16px 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { text-align: left; color: #6b7280; font-weight: 500; padding: 4px 8px; border-bottom: 1px solid #2a2d3a; position: sticky; top: 0; background: #0f1117; }
|
||||||
|
td { padding: 3px 8px; border-bottom: 1px solid #1a1d2e; white-space: nowrap; }
|
||||||
|
tr.highlight-push td { background: rgba(59,130,246,0.1); }
|
||||||
|
tr.highlight-set td { background: rgba(34,197,94,0.1); }
|
||||||
|
|
||||||
|
.replay-controls {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
|
||||||
|
background: #161822; border-bottom: 1px solid #2a2d3a;
|
||||||
|
}
|
||||||
|
.replay-controls button {
|
||||||
|
background: #2a2d3a; color: #e0e0e0; border: none; border-radius: 4px;
|
||||||
|
padding: 4px 10px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.replay-controls button:hover { background: #3b3f52; }
|
||||||
|
.replay-controls input[type=range] { flex: 1; }
|
||||||
|
.replay-controls .replay-pos { font-size: 12px; color: #9ca3af; min-width: 60px; text-align: right; }
|
||||||
|
.replay-controls .speed-group { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
||||||
|
.replay-controls .speed-group input {
|
||||||
|
width: 64px; background: #1a1d2e; color: #e0e0e0; border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 4px; padding: 2px 6px; font-size: 12px; text-align: right;
|
||||||
|
}
|
||||||
|
.replay-controls .speed-group label { font-size: 11px; color: #6b7280; white-space: nowrap; }
|
||||||
|
|
||||||
|
.node-flash { animation: flash 0.4s ease-out; }
|
||||||
|
@keyframes flash { 0% { filter: brightness(2); } 100% { filter: brightness(1); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gossip Simulation Dashboard</h1>
|
||||||
|
<div class="status-badge">
|
||||||
|
<div class="status-dot" id="statusDot"></div>
|
||||||
|
<span id="statusText">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="graph-panel">
|
||||||
|
<canvas id="graphCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="side-panel">
|
||||||
|
<div class="stats-panel">
|
||||||
|
<h2>Stats</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item"><div class="stat-value" id="statNodes">0</div><div class="stat-label">Nodes</div></div>
|
||||||
|
<div class="stat-item"><div class="stat-value" id="statEdges">0</div><div class="stat-label">Edges</div></div>
|
||||||
|
<div class="stat-item"><div class="stat-value" id="statMessages">0</div><div class="stat-label">Messages</div></div>
|
||||||
|
<div class="stat-item"><div class="stat-value" id="statRound">0/0</div><div class="stat-label">Round</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="worker-panel">
|
||||||
|
<h2>Worker Logs</h2>
|
||||||
|
<div class="worker-columns" id="workerColumns"></div>
|
||||||
|
</div>
|
||||||
|
<div class="event-panel">
|
||||||
|
<h2>Event Log</h2>
|
||||||
|
<div class="replay-controls" id="replayControls" style="display:none">
|
||||||
|
<button id="btnFirst" title="First">|◀</button>
|
||||||
|
<button id="btnPrev" title="Previous">◀</button>
|
||||||
|
<button id="btnPlay" title="Play">▶</button>
|
||||||
|
<button id="btnNext" title="Next">▶</button>
|
||||||
|
<button id="btnLast" title="Last">▶|</button>
|
||||||
|
<input type="range" id="replaySlider" min="0" max="0" value="0">
|
||||||
|
<span class="replay-pos" id="replayPos">0/0</span>
|
||||||
|
<span class="speed-group">
|
||||||
|
<input type="number" id="speedInput" value="100" min="10" max="2000" step="10">
|
||||||
|
<label>ms/event</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-table-wrap" id="eventTableWrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Seq</th><th>Round</th><th>Thread</th><th>Node</th><th>Event</th><th>Details</th></tr></thead>
|
||||||
|
<tbody id="eventTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MODE = "__DASHBOARD_MODE__"; // replaced by server: "live" or "replay"
|
||||||
|
const MAX_TABLE_ROWS = 2000;
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────
|
||||||
|
let nodes = [];
|
||||||
|
let edges = [];
|
||||||
|
let nodePositions = {};
|
||||||
|
let allEvents = [];
|
||||||
|
let replayCursor = 0;
|
||||||
|
let workerLogs = {};
|
||||||
|
let isDone = false;
|
||||||
|
|
||||||
|
// ── Canvas / Graph ─────────────────────────────────────────────────
|
||||||
|
const canvas = document.getElementById('graphCanvas');
|
||||||
|
const ctx2d = canvas.getContext('2d');
|
||||||
|
let nodeFlash = {}; // nodeName -> timestamp
|
||||||
|
let edgeFlash = {}; // "a->b" -> timestamp
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * devicePixelRatio;
|
||||||
|
canvas.height = rect.height * devicePixelRatio;
|
||||||
|
canvas.style.width = rect.width + 'px';
|
||||||
|
canvas.style.height = rect.height + 'px';
|
||||||
|
ctx2d.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
||||||
|
|
||||||
|
function initPositions() {
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
const cx = rect.width / 2, cy = rect.height / 2;
|
||||||
|
const r = Math.min(cx, cy) * 0.7;
|
||||||
|
const n = nodes.length;
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
|
||||||
|
nodePositions[node.name] = { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
|
||||||
|
});
|
||||||
|
// Run force simulation
|
||||||
|
runForceLayout(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runForceLayout(w, h) {
|
||||||
|
const pos = nodePositions;
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
const k = Math.sqrt((w * h) / Math.max(nodes.length, 1));
|
||||||
|
|
||||||
|
for (let iter = 0; iter < 300; iter++) {
|
||||||
|
const disp = {};
|
||||||
|
nodes.forEach(n => { disp[n.name] = { x: 0, y: 0 }; });
|
||||||
|
|
||||||
|
// Repulsion
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const a = nodes[i].name, b = nodes[j].name;
|
||||||
|
let dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y;
|
||||||
|
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||||
|
let force = (k * k) / dist;
|
||||||
|
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||||
|
disp[a].x += fx; disp[a].y += fy;
|
||||||
|
disp[b].x -= fx; disp[b].y -= fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction (edges)
|
||||||
|
edges.forEach(([a, b]) => {
|
||||||
|
if (!pos[a] || !pos[b]) return;
|
||||||
|
let dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y;
|
||||||
|
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||||
|
let force = (dist * dist) / k;
|
||||||
|
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||||
|
disp[a].x -= fx; disp[a].y -= fy;
|
||||||
|
disp[b].x += fx; disp[b].y += fy;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gravity toward center
|
||||||
|
nodes.forEach(n => {
|
||||||
|
let dx = pos[n.name].x - cx, dy = pos[n.name].y - cy;
|
||||||
|
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||||
|
disp[n.name].x -= dx * 0.01;
|
||||||
|
disp[n.name].y -= dy * 0.01;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply with damping
|
||||||
|
const temp = Math.max(0.1, 1 - iter / 300);
|
||||||
|
nodes.forEach(n => {
|
||||||
|
let d = disp[n.name];
|
||||||
|
let dist = Math.sqrt(d.x * d.x + d.y * d.y) || 0.01;
|
||||||
|
let cap = Math.min(dist, 10 * temp);
|
||||||
|
pos[n.name].x += (d.x / dist) * cap;
|
||||||
|
pos[n.name].y += (d.y / dist) * cap;
|
||||||
|
// Keep within bounds
|
||||||
|
pos[n.name].x = Math.max(40, Math.min(w - 40, pos[n.name].x));
|
||||||
|
pos[n.name].y = Math.max(40, Math.min(h - 40, pos[n.name].y));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGraph() {
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
const w = rect.width, h = rect.height;
|
||||||
|
ctx2d.clearRect(0, 0, w, h);
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
edges.forEach(([a, b]) => {
|
||||||
|
const pa = nodePositions[a], pb = nodePositions[b];
|
||||||
|
if (!pa || !pb) return;
|
||||||
|
const key = a + '->' + b;
|
||||||
|
const flash = edgeFlash[key];
|
||||||
|
let alpha = 0.25;
|
||||||
|
if (flash && now - flash < 600) {
|
||||||
|
alpha = 0.25 + 0.75 * (1 - (now - flash) / 600);
|
||||||
|
}
|
||||||
|
ctx2d.beginPath();
|
||||||
|
ctx2d.moveTo(pa.x, pa.y);
|
||||||
|
ctx2d.lineTo(pb.x, pb.y);
|
||||||
|
ctx2d.strokeStyle = `rgba(99, 102, 241, ${alpha})`;
|
||||||
|
ctx2d.lineWidth = flash && now - flash < 600 ? 2.5 : 1;
|
||||||
|
ctx2d.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const p = nodePositions[node.name];
|
||||||
|
if (!p) return;
|
||||||
|
const flash = nodeFlash[node.name];
|
||||||
|
let radius = 8;
|
||||||
|
let color = '#6366f1';
|
||||||
|
if (flash && now - flash < 400) {
|
||||||
|
const t = 1 - (now - flash) / 400;
|
||||||
|
radius = 8 + 6 * t;
|
||||||
|
color = '#818cf8';
|
||||||
|
}
|
||||||
|
ctx2d.beginPath();
|
||||||
|
ctx2d.arc(p.x, p.y, radius, 0, 2 * Math.PI);
|
||||||
|
ctx2d.fillStyle = color;
|
||||||
|
ctx2d.fill();
|
||||||
|
ctx2d.strokeStyle = '#4f46e5';
|
||||||
|
ctx2d.lineWidth = 1.5;
|
||||||
|
ctx2d.stroke();
|
||||||
|
|
||||||
|
ctx2d.fillStyle = '#c7d2fe';
|
||||||
|
ctx2d.font = '11px system-ui, sans-serif';
|
||||||
|
ctx2d.textAlign = 'center';
|
||||||
|
ctx2d.fillText(node.name, p.x, p.y + radius + 14);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Continue animation if flashes active
|
||||||
|
let anyFlash = false;
|
||||||
|
for (const t of Object.values(nodeFlash)) { if (now - t < 400) anyFlash = true; }
|
||||||
|
for (const t of Object.values(edgeFlash)) { if (now - t < 600) anyFlash = true; }
|
||||||
|
if (anyFlash) requestAnimationFrame(drawGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI updates ─────────────────────────────────────────────────────
|
||||||
|
function updateStats(s) {
|
||||||
|
document.getElementById('statNodes').textContent = s.total_nodes;
|
||||||
|
document.getElementById('statEdges').textContent = s.total_edges;
|
||||||
|
document.getElementById('statMessages').textContent = s.total_messages;
|
||||||
|
document.getElementById('statRound').textContent = s.current_round + '/' + s.total_rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRow(ev) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (ev.kind === 'GossipRoundStarted') tr.className = 'highlight-push';
|
||||||
|
if (ev.kind === 'LocalSet') tr.className = 'highlight-set';
|
||||||
|
const detailStr = formatDetail(ev.kind, ev.detail);
|
||||||
|
tr.innerHTML = `<td>${ev.seq}</td><td>${ev.tick}</td><td>${ev.thread || '-'}</td><td>${ev.node}</td><td>${ev.kind}</td><td>${detailStr}</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEventToTable(ev) {
|
||||||
|
const tbody = document.getElementById('eventTableBody');
|
||||||
|
if (tbody.children.length >= MAX_TABLE_ROWS) {
|
||||||
|
tbody.removeChild(tbody.firstChild);
|
||||||
|
}
|
||||||
|
tbody.appendChild(makeRow(ev));
|
||||||
|
|
||||||
|
const wrap = document.getElementById('eventTableWrap');
|
||||||
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(kind, detail) {
|
||||||
|
if (!detail) return '';
|
||||||
|
switch (kind) {
|
||||||
|
case 'GossipRoundStarted': return '→ ' + detail.target;
|
||||||
|
case 'PushReceived': return '← ' + detail.from + ' (' + detail.keys_updated + ' keys)';
|
||||||
|
case 'LocalSet': return 'key=' + detail.key;
|
||||||
|
case 'PeerAdded': return '+ ' + detail.peer;
|
||||||
|
case 'PeerRemoved': return '- ' + detail.peer;
|
||||||
|
case 'QueryReceived': return 'key=' + detail.key;
|
||||||
|
case 'StateSnapshot': return detail.entries + ' entries, ' + detail.peer_count + ' peers';
|
||||||
|
default: return JSON.stringify(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWorkerEntry(thread, text) {
|
||||||
|
if (!thread) return;
|
||||||
|
if (!workerLogs[thread]) {
|
||||||
|
workerLogs[thread] = [];
|
||||||
|
rebuildWorkerColumns();
|
||||||
|
}
|
||||||
|
workerLogs[thread].push(text);
|
||||||
|
if (workerLogs[thread].length > 50) workerLogs[thread].shift();
|
||||||
|
updateWorkerColumn(thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildWorkerColumns() {
|
||||||
|
const container = document.getElementById('workerColumns');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const thread of Object.keys(workerLogs).sort()) {
|
||||||
|
const col = document.createElement('div');
|
||||||
|
col.className = 'worker-col';
|
||||||
|
col.id = 'worker-' + thread;
|
||||||
|
col.innerHTML = '<div class="worker-col-header">' + thread + '</div>';
|
||||||
|
container.appendChild(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorkerColumn(thread) {
|
||||||
|
const col = document.getElementById('worker-' + thread);
|
||||||
|
if (!col) return;
|
||||||
|
const entries = workerLogs[thread];
|
||||||
|
// Keep header + entries
|
||||||
|
let html = '<div class="worker-col-header">' + thread + '</div>';
|
||||||
|
for (const e of entries) {
|
||||||
|
html += '<div class="worker-entry">' + e + '</div>';
|
||||||
|
}
|
||||||
|
col.innerHTML = html;
|
||||||
|
col.scrollTop = col.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processEvent(ev) {
|
||||||
|
addEventToTable(ev);
|
||||||
|
|
||||||
|
// Flash node
|
||||||
|
nodeFlash[ev.node] = performance.now();
|
||||||
|
|
||||||
|
// Flash edges on Push
|
||||||
|
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
||||||
|
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
||||||
|
edgeFlash[ev.detail.target + '->' + ev.node] = performance.now();
|
||||||
|
}
|
||||||
|
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
||||||
|
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
||||||
|
edgeFlash[ev.node + '->' + ev.detail.from] = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker log
|
||||||
|
let text = ev.node + ': ' + ev.kind;
|
||||||
|
if (ev.kind === 'GossipRoundStarted') text += ' -> ' + ev.detail.target;
|
||||||
|
if (ev.kind === 'PushReceived') text += ' <- ' + ev.detail.from;
|
||||||
|
addWorkerEntry(ev.thread, text);
|
||||||
|
|
||||||
|
requestAnimationFrame(drawGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live mode (SSE) ────────────────────────────────────────────────
|
||||||
|
function startLive() {
|
||||||
|
const dot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
|
||||||
|
es.addEventListener('init', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
nodes = data.nodes;
|
||||||
|
edges = data.edges;
|
||||||
|
statusText.textContent = 'Live: ' + data.name;
|
||||||
|
dot.className = 'status-dot';
|
||||||
|
resizeCanvas();
|
||||||
|
initPositions();
|
||||||
|
drawGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('gossip', (e) => {
|
||||||
|
const ev = JSON.parse(e.data);
|
||||||
|
allEvents.push(ev);
|
||||||
|
processEvent(ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('stats', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
updateStats(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', (e) => {
|
||||||
|
isDone = true;
|
||||||
|
dot.className = 'status-dot done';
|
||||||
|
statusText.textContent += ' (done)';
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (!isDone) {
|
||||||
|
statusText.textContent = 'Disconnected';
|
||||||
|
dot.style.background = '#ef4444';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Replay mode ────────────────────────────────────────────────────
|
||||||
|
async function startReplay() {
|
||||||
|
const dot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
dot.className = 'status-dot replay';
|
||||||
|
|
||||||
|
const resp = await fetch('/trace.json');
|
||||||
|
const trace = await resp.json();
|
||||||
|
|
||||||
|
nodes = trace.node_names.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
addr: trace.node_addrs[i] ? JSON.stringify(trace.node_addrs[i]) : ''
|
||||||
|
}));
|
||||||
|
edges = trace.topology_edges.map(e => [e[0], e[1]]);
|
||||||
|
|
||||||
|
// Build events list (filter out StateSnapshot for display)
|
||||||
|
let seq = 0;
|
||||||
|
allEvents = trace.events
|
||||||
|
.filter(ev => ev.kind !== 'StateSnapshot')
|
||||||
|
.map(ev => {
|
||||||
|
const kind = typeof ev.kind === 'string' ? ev.kind : Object.keys(ev.kind)[0];
|
||||||
|
const detail = typeof ev.kind === 'string' ? {} : ev.kind[kind] || {};
|
||||||
|
return {
|
||||||
|
seq: seq++,
|
||||||
|
tick: ev.tick,
|
||||||
|
node: ev.node_name,
|
||||||
|
thread: ev.thread_name,
|
||||||
|
kind,
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
statusText.textContent = 'Replay: ' + trace.name + ' (' + allEvents.length + ' events)';
|
||||||
|
updateStats({
|
||||||
|
total_nodes: trace.node_names.length,
|
||||||
|
total_edges: trace.topology_edges.length,
|
||||||
|
total_messages: allEvents.length,
|
||||||
|
current_round: trace.num_rounds,
|
||||||
|
total_rounds: trace.num_rounds
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
initPositions();
|
||||||
|
drawGraph();
|
||||||
|
|
||||||
|
// Show replay controls
|
||||||
|
const controls = document.getElementById('replayControls');
|
||||||
|
controls.style.display = 'flex';
|
||||||
|
const slider = document.getElementById('replaySlider');
|
||||||
|
slider.max = allEvents.length;
|
||||||
|
slider.value = 0;
|
||||||
|
replayCursor = 0;
|
||||||
|
updateReplayPos();
|
||||||
|
|
||||||
|
document.getElementById('btnFirst').onclick = () => { stopPlayback(); replayTo(0); };
|
||||||
|
document.getElementById('btnPrev').onclick = () => { stopPlayback(); replayTo(Math.max(0, replayCursor - 1)); };
|
||||||
|
document.getElementById('btnNext').onclick = () => { stopPlayback(); replayTo(Math.min(allEvents.length, replayCursor + 1)); };
|
||||||
|
document.getElementById('btnLast').onclick = () => { stopPlayback(); replayTo(allEvents.length); };
|
||||||
|
slider.oninput = () => { stopPlayback(); replayTo(parseInt(slider.value)); };
|
||||||
|
|
||||||
|
document.getElementById('btnPlay').onclick = () => {
|
||||||
|
if (playTimer !== null) { stopPlayback(); } else { startPlayback(); }
|
||||||
|
};
|
||||||
|
document.getElementById('speedInput').onchange = () => {
|
||||||
|
if (playTimer !== null) { startPlayback(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let replayRafId = 0;
|
||||||
|
let playTimer = null;
|
||||||
|
|
||||||
|
function stopPlayback() {
|
||||||
|
if (playTimer !== null) {
|
||||||
|
clearInterval(playTimer);
|
||||||
|
playTimer = null;
|
||||||
|
document.getElementById('btnPlay').innerHTML = '▶';
|
||||||
|
document.getElementById('btnPlay').title = 'Play';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlayback() {
|
||||||
|
stopPlayback();
|
||||||
|
if (replayCursor >= allEvents.length) return; // already at end
|
||||||
|
const speed = parseInt(document.getElementById('speedInput').value) || 100;
|
||||||
|
document.getElementById('btnPlay').innerHTML = '⏸';
|
||||||
|
document.getElementById('btnPlay').title = 'Pause';
|
||||||
|
playTimer = setInterval(() => {
|
||||||
|
if (replayCursor >= allEvents.length) {
|
||||||
|
stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replayTo(replayCursor + 1);
|
||||||
|
}, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayTo(pos) {
|
||||||
|
// Debounce: only run once per animation frame
|
||||||
|
replayCursor = pos;
|
||||||
|
if (replayRafId) return;
|
||||||
|
replayRafId = requestAnimationFrame(() => {
|
||||||
|
replayRafId = 0;
|
||||||
|
replayToImpl(replayCursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayToImpl(pos) {
|
||||||
|
nodeFlash = {};
|
||||||
|
edgeFlash = {};
|
||||||
|
|
||||||
|
// ── Build table rows into a fragment (no reflows) ──
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const start = Math.max(0, pos - MAX_TABLE_ROWS);
|
||||||
|
for (let i = start; i < pos && i < allEvents.length; i++) {
|
||||||
|
frag.appendChild(makeRow(allEvents[i]));
|
||||||
|
}
|
||||||
|
const tbody = document.getElementById('eventTableBody');
|
||||||
|
tbody.textContent = ''; // fast clear
|
||||||
|
tbody.appendChild(frag);
|
||||||
|
|
||||||
|
const wrap = document.getElementById('eventTableWrap');
|
||||||
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
|
||||||
|
// ── Rebuild worker logs in one pass ──
|
||||||
|
workerLogs = {};
|
||||||
|
const workerStart = Math.max(0, pos - 200); // only last ~200 events for worker logs
|
||||||
|
for (let i = workerStart; i < pos && i < allEvents.length; i++) {
|
||||||
|
const ev = allEvents[i];
|
||||||
|
if (!ev.thread) continue;
|
||||||
|
if (!workerLogs[ev.thread]) workerLogs[ev.thread] = [];
|
||||||
|
let text = ev.node + ': ' + ev.kind;
|
||||||
|
if (ev.kind === 'GossipRoundStarted' && ev.detail) text += ' -> ' + ev.detail.target;
|
||||||
|
if (ev.kind === 'PushReceived' && ev.detail) text += ' <- ' + ev.detail.from;
|
||||||
|
workerLogs[ev.thread].push(text);
|
||||||
|
if (workerLogs[ev.thread].length > 50) workerLogs[ev.thread].shift();
|
||||||
|
}
|
||||||
|
rebuildWorkerColumns();
|
||||||
|
for (const thread of Object.keys(workerLogs)) {
|
||||||
|
updateWorkerColumn(thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash the last event
|
||||||
|
if (pos > 0 && pos <= allEvents.length) {
|
||||||
|
const ev = allEvents[pos - 1];
|
||||||
|
nodeFlash[ev.node] = performance.now();
|
||||||
|
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
||||||
|
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
||||||
|
}
|
||||||
|
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
||||||
|
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('replaySlider').value = pos;
|
||||||
|
updateReplayPos();
|
||||||
|
drawGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReplayPos() {
|
||||||
|
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ───────────────────────────────────────────────────────────
|
||||||
|
resizeCanvas();
|
||||||
|
if (MODE === 'replay') {
|
||||||
|
startReplay();
|
||||||
|
} else {
|
||||||
|
startLive();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##;
|
||||||
23
crates/gossip-dashboard/src/lib.rs
Normal file
23
crates/gossip-dashboard/src/lib.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
pub mod config;
|
||||||
|
mod dashboard_html;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
pub use server::{DashboardConfig, run_with_dashboard, serve_replay};
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use swactor_gossip::trace::SimulationTrace;
|
||||||
|
|
||||||
|
pub fn save_trace(trace: &SimulationTrace, path: &str) -> io::Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(trace)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||||
|
fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_trace(path: &str) -> io::Result<SimulationTrace> {
|
||||||
|
let data = fs::read_to_string(path)?;
|
||||||
|
let trace: SimulationTrace =
|
||||||
|
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||||
|
Ok(trace)
|
||||||
|
}
|
||||||
752
crates/gossip-dashboard/src/server.rs
Normal file
752
crates/gossip-dashboard/src/server.rs
Normal file
|
|
@ -0,0 +1,752 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, Read as IoRead};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
use swactor_gossip::protocol::{GossipActor, GossipMessage};
|
||||||
|
use swactor_gossip::sim::{heal_partition_via_handle, wire_topology, SimConfig};
|
||||||
|
use swactor_gossip::trace::{
|
||||||
|
EventLog, GossipEvent, GossipEventKind, NameRegistry, NodeSnapshot, SimulationTrace,
|
||||||
|
TickCounter,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::dashboard_html::DASHBOARD_HTML;
|
||||||
|
|
||||||
|
// ── Configuration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DashboardConfig {
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DashboardConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { port: 8080 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared dashboard state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct DashboardState {
|
||||||
|
event_log: EventLog,
|
||||||
|
name_registry: NameRegistry,
|
||||||
|
init_data: Mutex<Option<InitData>>,
|
||||||
|
stats: Mutex<StatsSnapshot>,
|
||||||
|
done: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct InitData {
|
||||||
|
name: String,
|
||||||
|
nodes: Vec<NodeInfo>,
|
||||||
|
edges: Vec<[String; 2]>,
|
||||||
|
num_threads: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct NodeInfo {
|
||||||
|
name: String,
|
||||||
|
addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
struct StatsSnapshot {
|
||||||
|
total_nodes: usize,
|
||||||
|
total_edges: usize,
|
||||||
|
total_messages: usize,
|
||||||
|
current_round: u64,
|
||||||
|
total_rounds: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE channel adapter ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Adapts an `mpsc::Receiver<Vec<u8>>` to `std::io::Read` for tiny_http streaming.
|
||||||
|
struct ChannelReader {
|
||||||
|
rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
buf: Vec<u8>,
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelReader {
|
||||||
|
fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
|
||||||
|
Self {
|
||||||
|
rx,
|
||||||
|
buf: Vec::new(),
|
||||||
|
pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IoRead for ChannelReader {
|
||||||
|
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
|
||||||
|
// Drain current buffer first.
|
||||||
|
if self.pos < self.buf.len() {
|
||||||
|
let n = std::cmp::min(out.len(), self.buf.len() - self.pos);
|
||||||
|
out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
|
||||||
|
self.pos += n;
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for next chunk.
|
||||||
|
match self.rx.recv() {
|
||||||
|
Ok(data) => {
|
||||||
|
if data.is_empty() {
|
||||||
|
return Ok(0); // EOF signal
|
||||||
|
}
|
||||||
|
let n = std::cmp::min(out.len(), data.len());
|
||||||
|
out[..n].copy_from_slice(&data[..n]);
|
||||||
|
if n < data.len() {
|
||||||
|
self.buf = data;
|
||||||
|
self.pos = n;
|
||||||
|
} else {
|
||||||
|
self.buf.clear();
|
||||||
|
self.pos = 0;
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
Err(_) => Ok(0), // channel closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE formatting helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn format_sse(event: &str, data: &str) -> Vec<u8> {
|
||||||
|
format!("event: {event}\ndata: {data}\n\n").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_kind_name(kind: &GossipEventKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
GossipEventKind::LocalSet { .. } => "LocalSet",
|
||||||
|
GossipEventKind::GossipRoundStarted { .. } => "GossipRoundStarted",
|
||||||
|
GossipEventKind::GossipRoundNoPeers => "GossipRoundNoPeers",
|
||||||
|
GossipEventKind::PushReceived { .. } => "PushReceived",
|
||||||
|
GossipEventKind::QueryReceived { .. } => "QueryReceived",
|
||||||
|
GossipEventKind::PeerAdded { .. } => "PeerAdded",
|
||||||
|
GossipEventKind::PeerRemoved { .. } => "PeerRemoved",
|
||||||
|
GossipEventKind::StateSnapshot { .. } => "StateSnapshot",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SseGossipEvent {
|
||||||
|
seq: usize,
|
||||||
|
tick: u64,
|
||||||
|
node: String,
|
||||||
|
thread: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
detail: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gossip_event_to_sse(seq: usize, ev: &GossipEvent) -> SseGossipEvent {
|
||||||
|
let detail = match &ev.kind {
|
||||||
|
GossipEventKind::LocalSet { key } => {
|
||||||
|
serde_json::json!({ "key": key })
|
||||||
|
}
|
||||||
|
GossipEventKind::GossipRoundStarted { target_name } => {
|
||||||
|
serde_json::json!({ "target": target_name })
|
||||||
|
}
|
||||||
|
GossipEventKind::GossipRoundNoPeers => serde_json::json!({}),
|
||||||
|
GossipEventKind::PushReceived {
|
||||||
|
from_name,
|
||||||
|
keys_updated,
|
||||||
|
} => {
|
||||||
|
serde_json::json!({ "from": from_name, "keys_updated": keys_updated })
|
||||||
|
}
|
||||||
|
GossipEventKind::QueryReceived { key } => {
|
||||||
|
serde_json::json!({ "key": key })
|
||||||
|
}
|
||||||
|
GossipEventKind::PeerAdded { peer_name } => {
|
||||||
|
serde_json::json!({ "peer": peer_name })
|
||||||
|
}
|
||||||
|
GossipEventKind::PeerRemoved { peer_name } => {
|
||||||
|
serde_json::json!({ "peer": peer_name })
|
||||||
|
}
|
||||||
|
GossipEventKind::StateSnapshot { snapshot } => {
|
||||||
|
serde_json::json!({
|
||||||
|
"entries": snapshot.entries.len(),
|
||||||
|
"peer_count": snapshot.peer_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SseGossipEvent {
|
||||||
|
seq,
|
||||||
|
tick: ev.tick,
|
||||||
|
node: ev.node_name.clone(),
|
||||||
|
thread: ev.thread_name.clone(),
|
||||||
|
kind: event_kind_name(&ev.kind).to_string(),
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP server ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn spawn_http_server(state: Arc<DashboardState>, port: u16, mode: &str) {
|
||||||
|
let addr = format!("0.0.0.0:{port}");
|
||||||
|
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
||||||
|
let server = Arc::new(server);
|
||||||
|
let mode = mode.to_string();
|
||||||
|
|
||||||
|
// Spawn a pool of handler threads.
|
||||||
|
for _ in 0..4 {
|
||||||
|
let server = Arc::clone(&server);
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
let mode = mode.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let request = match server.recv() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = request.url().to_string();
|
||||||
|
match url.as_str() {
|
||||||
|
"/" => {
|
||||||
|
let html = DASHBOARD_HTML.replace("__DASHBOARD_MODE__", &mode);
|
||||||
|
let response = tiny_http::Response::from_string(html)
|
||||||
|
.with_header(
|
||||||
|
"Content-Type: text/html; charset=utf-8"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
"/events" => {
|
||||||
|
handle_sse(request, Arc::clone(&state));
|
||||||
|
}
|
||||||
|
"/trace.json" => {
|
||||||
|
handle_trace_json(request, Arc::clone(&state));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let response =
|
||||||
|
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_sse(request: tiny_http::Request, state: Arc<DashboardState>) {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||||
|
let reader = ChannelReader::new(rx);
|
||||||
|
|
||||||
|
// Send SSE headers via a streaming response.
|
||||||
|
let response = tiny_http::Response::new(
|
||||||
|
tiny_http::StatusCode(200),
|
||||||
|
vec![
|
||||||
|
"Content-Type: text/event-stream"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
"Cache-Control: no-cache"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
"Connection: keep-alive"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
],
|
||||||
|
Box::new(reader) as Box<dyn IoRead + Send>,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawn producer thread that polls for new events.
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut cursor: usize = 0;
|
||||||
|
|
||||||
|
// Wait for init data.
|
||||||
|
loop {
|
||||||
|
if let Some(init) = state.init_data.lock().unwrap().as_ref() {
|
||||||
|
let json = serde_json::to_string(init).unwrap();
|
||||||
|
if tx.send(format_sse("init", &json)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for events.
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let log = state.event_log.lock().unwrap();
|
||||||
|
while cursor < log.len() {
|
||||||
|
let ev = &log[cursor];
|
||||||
|
// Filter out StateSnapshot events from SSE stream.
|
||||||
|
if !matches!(ev.kind, GossipEventKind::StateSnapshot { .. }) {
|
||||||
|
let sse_ev = gossip_event_to_sse(cursor, ev);
|
||||||
|
let json = serde_json::to_string(&sse_ev).unwrap();
|
||||||
|
if tx.send(format_sse("gossip", &json)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send stats update.
|
||||||
|
{
|
||||||
|
let stats = state.stats.lock().unwrap().clone();
|
||||||
|
let json = serde_json::to_string(&stats).unwrap();
|
||||||
|
if tx.send(format_sse("stats", &json)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.done.load(Ordering::Relaxed) {
|
||||||
|
let _ = tx.send(format_sse("done", "{}"));
|
||||||
|
let _ = tx.send(Vec::new()); // EOF
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// This blocks until the reader is consumed / connection closes.
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_trace_json(request: tiny_http::Request, state: Arc<DashboardState>) {
|
||||||
|
// Build a partial trace from current state.
|
||||||
|
let events = state.event_log.lock().unwrap().clone();
|
||||||
|
let names_map = state.name_registry.lock().unwrap().clone();
|
||||||
|
let init = state.init_data.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let trace = SimulationTrace {
|
||||||
|
name: init.as_ref().map(|i| i.name.clone()).unwrap_or_default(),
|
||||||
|
node_names: init
|
||||||
|
.as_ref()
|
||||||
|
.map(|i| i.nodes.iter().map(|n| n.name.clone()).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
node_addrs: {
|
||||||
|
let mut addrs: Vec<ActorAddress> = Vec::new();
|
||||||
|
if let Some(init) = &init {
|
||||||
|
// Reconstruct addrs from name_registry in node order.
|
||||||
|
let inv: HashMap<String, ActorAddress> =
|
||||||
|
names_map.into_iter().map(|(a, n)| (n, a)).collect();
|
||||||
|
for node in &init.nodes {
|
||||||
|
if let Some(&addr) = inv.get(&node.name) {
|
||||||
|
addrs.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addrs
|
||||||
|
},
|
||||||
|
topology_edges: init
|
||||||
|
.as_ref()
|
||||||
|
.map(|i| {
|
||||||
|
i.edges
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e[0].clone(), e[1].clone()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
events,
|
||||||
|
snapshots_per_round: Vec::new(),
|
||||||
|
num_rounds: init
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| {
|
||||||
|
state
|
||||||
|
.stats
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.total_rounds
|
||||||
|
})
|
||||||
|
.unwrap_or(0),
|
||||||
|
total_keys: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&trace).unwrap();
|
||||||
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
"Content-Type: application/json"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API: run_with_dashboard ─────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn run_with_dashboard(config: SimConfig, dash: DashboardConfig) -> SimulationTrace {
|
||||||
|
let num_threads = config.num_threads.max(1);
|
||||||
|
let event_log: EventLog = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let tick_counter: TickCounter = Arc::new(AtomicU64::new(0));
|
||||||
|
let name_registry: NameRegistry = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let state = Arc::new(DashboardState {
|
||||||
|
event_log: Arc::clone(&event_log),
|
||||||
|
name_registry: Arc::clone(&name_registry),
|
||||||
|
init_data: Mutex::new(None),
|
||||||
|
stats: Mutex::new(StatsSnapshot::default()),
|
||||||
|
done: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start HTTP server.
|
||||||
|
spawn_http_server(Arc::clone(&state), dash.port, "live");
|
||||||
|
|
||||||
|
if num_threads < 2 {
|
||||||
|
run_dashboard_single_threaded(config, state, event_log, tick_counter, name_registry)
|
||||||
|
} else {
|
||||||
|
run_dashboard_multi_threaded(config, state, event_log, tick_counter, name_registry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dashboard_single_threaded(
|
||||||
|
config: SimConfig,
|
||||||
|
state: Arc<DashboardState>,
|
||||||
|
event_log: EventLog,
|
||||||
|
tick_counter: TickCounter,
|
||||||
|
name_registry: NameRegistry,
|
||||||
|
) -> SimulationTrace {
|
||||||
|
let rt = Runtime::new(RuntimeConfig {
|
||||||
|
num_threads: 1,
|
||||||
|
max_actors: (config.num_nodes + 64).next_power_of_two(),
|
||||||
|
actor_max_messages: (config.num_nodes * 4).max(1_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut addrs = Vec::with_capacity(config.num_nodes);
|
||||||
|
let mut names = Vec::with_capacity(config.num_nodes);
|
||||||
|
for i in 0..config.num_nodes {
|
||||||
|
let name = format!("node-{i}");
|
||||||
|
let actor = GossipActor::traced(
|
||||||
|
Arc::clone(&event_log),
|
||||||
|
Arc::clone(&tick_counter),
|
||||||
|
Arc::clone(&name_registry),
|
||||||
|
);
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
name_registry.lock().unwrap().insert(addr, name.clone());
|
||||||
|
addrs.push(addr);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let edges = wire_topology(&rt, &config.topology, &addrs, &names);
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish init data.
|
||||||
|
publish_init(&state, &config, &addrs, &names, &edges);
|
||||||
|
|
||||||
|
let total_keys = config.initial_data.len();
|
||||||
|
for (key, value) in &config.initial_data {
|
||||||
|
rt.send_to(
|
||||||
|
addrs[0],
|
||||||
|
GossipMessage::Set {
|
||||||
|
key: key.clone(),
|
||||||
|
value: value.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let mut snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>> = Vec::new();
|
||||||
|
|
||||||
|
for round in 0..config.num_rounds {
|
||||||
|
if config.heal_after_round == Some(round) {
|
||||||
|
swactor_gossip::sim::heal_partition(&rt, &config.topology, &addrs, &names);
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick_counter.store((round + 1) as u64, Ordering::Relaxed);
|
||||||
|
update_stats(&state, &config, round, &event_log);
|
||||||
|
|
||||||
|
for &addr in &addrs {
|
||||||
|
rt.send_to(addr, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..config.ticks_per_round {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
for &addr in &addrs {
|
||||||
|
rt.send_to(addr, GossipMessage::TakeSnapshot).unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_round_tick = (round + 1) as u64;
|
||||||
|
let log = event_log.lock().unwrap();
|
||||||
|
let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new();
|
||||||
|
for event in log.iter().rev() {
|
||||||
|
if event.tick != current_round_tick {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
|
||||||
|
round_snapshots.push((event.node_name.clone(), snapshot.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
round_snapshots.reverse();
|
||||||
|
snapshots_per_round.push(round_snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.done.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let events = event_log.lock().unwrap().clone();
|
||||||
|
SimulationTrace {
|
||||||
|
name: config.name,
|
||||||
|
node_names: names,
|
||||||
|
node_addrs: addrs,
|
||||||
|
topology_edges: edges,
|
||||||
|
events,
|
||||||
|
snapshots_per_round,
|
||||||
|
num_rounds: config.num_rounds,
|
||||||
|
total_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dashboard_multi_threaded(
|
||||||
|
config: SimConfig,
|
||||||
|
state: Arc<DashboardState>,
|
||||||
|
event_log: EventLog,
|
||||||
|
tick_counter: TickCounter,
|
||||||
|
name_registry: NameRegistry,
|
||||||
|
) -> SimulationTrace {
|
||||||
|
let ticks_per_round = config.ticks_per_round;
|
||||||
|
let settle_ms = (ticks_per_round as u64 * 2).max(10);
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig {
|
||||||
|
num_threads: config.num_threads,
|
||||||
|
max_actors: (config.num_nodes + 64).next_power_of_two(),
|
||||||
|
actor_max_messages: (config.num_nodes * 4).max(1_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut addrs = Vec::with_capacity(config.num_nodes);
|
||||||
|
let mut names = Vec::with_capacity(config.num_nodes);
|
||||||
|
for i in 0..config.num_nodes {
|
||||||
|
let name = format!("node-{i}");
|
||||||
|
let actor = GossipActor::traced(
|
||||||
|
Arc::clone(&event_log),
|
||||||
|
Arc::clone(&tick_counter),
|
||||||
|
Arc::clone(&name_registry),
|
||||||
|
);
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
name_registry.lock().unwrap().insert(addr, name.clone());
|
||||||
|
addrs.push(addr);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let edges = wire_topology(&rt, &config.topology, &addrs, &names);
|
||||||
|
|
||||||
|
// Publish init data.
|
||||||
|
publish_init(&state, &config, &addrs, &names, &edges);
|
||||||
|
|
||||||
|
let total_keys = config.initial_data.len();
|
||||||
|
for (key, value) in &config.initial_data {
|
||||||
|
rt.send_to(
|
||||||
|
addrs[0],
|
||||||
|
GossipMessage::Set {
|
||||||
|
key: key.clone(),
|
||||||
|
value: value.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = rt.run().expect("failed to start multi-threaded runtime");
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms * 2));
|
||||||
|
|
||||||
|
let mut snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>> = Vec::new();
|
||||||
|
|
||||||
|
for round in 0..config.num_rounds {
|
||||||
|
if config.heal_after_round == Some(round) {
|
||||||
|
heal_partition_via_handle(&handle, &config.topology, &addrs, &names);
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
tick_counter.store((round + 1) as u64, Ordering::Relaxed);
|
||||||
|
update_stats(&state, &config, round, &event_log);
|
||||||
|
|
||||||
|
for &addr in &addrs {
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addr, GossipMessage::DoGossipRound)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms));
|
||||||
|
|
||||||
|
for &addr in &addrs {
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addr, GossipMessage::TakeSnapshot)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms / 2));
|
||||||
|
|
||||||
|
let current_round_tick = (round + 1) as u64;
|
||||||
|
let log = event_log.lock().unwrap();
|
||||||
|
let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new();
|
||||||
|
for event in log.iter().rev() {
|
||||||
|
if event.tick != current_round_tick {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
|
||||||
|
round_snapshots.push((event.node_name.clone(), snapshot.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
round_snapshots.reverse();
|
||||||
|
snapshots_per_round.push(round_snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
|
||||||
|
state.done.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let events = event_log.lock().unwrap().clone();
|
||||||
|
SimulationTrace {
|
||||||
|
name: config.name,
|
||||||
|
node_names: names,
|
||||||
|
node_addrs: addrs,
|
||||||
|
topology_edges: edges,
|
||||||
|
events,
|
||||||
|
snapshots_per_round,
|
||||||
|
num_rounds: config.num_rounds,
|
||||||
|
total_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn publish_init(
|
||||||
|
state: &DashboardState,
|
||||||
|
config: &SimConfig,
|
||||||
|
addrs: &[ActorAddress],
|
||||||
|
names: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
) {
|
||||||
|
let nodes: Vec<NodeInfo> = names
|
||||||
|
.iter()
|
||||||
|
.zip(addrs.iter())
|
||||||
|
.map(|(name, addr)| NodeInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
addr: format!("{addr}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let edge_pairs: Vec<[String; 2]> = edges
|
||||||
|
.iter()
|
||||||
|
.map(|(a, b)| [a.clone(), b.clone()])
|
||||||
|
.collect();
|
||||||
|
*state.init_data.lock().unwrap() = Some(InitData {
|
||||||
|
name: config.name.clone(),
|
||||||
|
nodes,
|
||||||
|
edges: edge_pairs,
|
||||||
|
num_threads: config.num_threads,
|
||||||
|
});
|
||||||
|
*state.stats.lock().unwrap() = StatsSnapshot {
|
||||||
|
total_nodes: config.num_nodes,
|
||||||
|
total_edges: edges.len(),
|
||||||
|
total_messages: 0,
|
||||||
|
current_round: 0,
|
||||||
|
total_rounds: config.num_rounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_stats(state: &DashboardState, config: &SimConfig, round: usize, event_log: &EventLog) {
|
||||||
|
let msg_count = event_log.lock().unwrap().len();
|
||||||
|
let mut stats = state.stats.lock().unwrap();
|
||||||
|
stats.current_round = (round + 1) as u64;
|
||||||
|
stats.total_messages = msg_count;
|
||||||
|
stats.total_rounds = config.num_rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Replay mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn serve_replay(trace: &SimulationTrace, port: u16) {
|
||||||
|
let event_log: EventLog = Arc::new(Mutex::new(trace.events.clone()));
|
||||||
|
let name_registry: NameRegistry = Arc::new(Mutex::new(
|
||||||
|
trace
|
||||||
|
.node_names
|
||||||
|
.iter()
|
||||||
|
.zip(trace.node_addrs.iter())
|
||||||
|
.map(|(n, a)| (*a, n.clone()))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let edges: Vec<[String; 2]> = trace
|
||||||
|
.topology_edges
|
||||||
|
.iter()
|
||||||
|
.map(|(a, b)| [a.clone(), b.clone()])
|
||||||
|
.collect();
|
||||||
|
let nodes: Vec<NodeInfo> = trace
|
||||||
|
.node_names
|
||||||
|
.iter()
|
||||||
|
.zip(trace.node_addrs.iter())
|
||||||
|
.map(|(name, addr)| NodeInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
addr: format!("{addr}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Keep state alive for potential future SSE support in replay mode.
|
||||||
|
let _state = Arc::new(DashboardState {
|
||||||
|
event_log,
|
||||||
|
name_registry,
|
||||||
|
init_data: Mutex::new(Some(InitData {
|
||||||
|
name: trace.name.clone(),
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
num_threads: 1,
|
||||||
|
})),
|
||||||
|
stats: Mutex::new(StatsSnapshot {
|
||||||
|
total_nodes: trace.node_names.len(),
|
||||||
|
total_edges: trace.topology_edges.len(),
|
||||||
|
total_messages: trace.events.len(),
|
||||||
|
current_round: trace.num_rounds as u64,
|
||||||
|
total_rounds: trace.num_rounds,
|
||||||
|
}),
|
||||||
|
done: AtomicBool::new(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{port}");
|
||||||
|
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
||||||
|
|
||||||
|
eprintln!("Replay dashboard at http://localhost:{port}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let request = match server.recv() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = request.url().to_string();
|
||||||
|
match url.as_str() {
|
||||||
|
"/" => {
|
||||||
|
let html = DASHBOARD_HTML.replace("__DASHBOARD_MODE__", "replay");
|
||||||
|
let response = tiny_http::Response::from_string(html).with_header(
|
||||||
|
"Content-Type: text/html; charset=utf-8"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
"/trace.json" => {
|
||||||
|
let json = serde_json::to_string(trace).unwrap();
|
||||||
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
"Content-Type: application/json"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let response =
|
||||||
|
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/swactor-gossip/Cargo.toml
Normal file
11
crates/swactor-gossip/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "swactor-gossip"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
swactor = { path = "../..", features = ["serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
getrandom = "0.2"
|
||||||
|
log = "0.4"
|
||||||
41
crates/swactor-gossip/README.md
Normal file
41
crates/swactor-gossip/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# swactor-gossip
|
||||||
|
|
||||||
|
Epidemic gossip protocol built on the [swactor](../../) actor runtime.
|
||||||
|
|
||||||
|
Nodes exchange state via randomized push-gossip and converge to a consistent view using last-writer-wins versioned values.
|
||||||
|
|
||||||
|
## Crate layout
|
||||||
|
|
||||||
|
- **`protocol`** — `GossipActor`, `GossipMessage`, `GossipState` (the core protocol implementation)
|
||||||
|
- **`sim`** — simulation harness with configurable topologies (Ring, Star, FullMesh, Chain, Partitioned) and optional partition healing
|
||||||
|
- **`trace`** — per-tick event log and `SimulationTrace` for post-run analysis
|
||||||
|
- **`properties`** — metrics extraction (delivery ratio, convergence round, redundancy, load balance, etc.) and property-based assertions over traces
|
||||||
|
- **`report`** / **`property_report`** — self-contained HTML report generators for single-run and multi-scenario results
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### Dashboard (`gossip-dashboard` crate)
|
||||||
|
|
||||||
|
A live web dashboard that streams simulation progress to a browser in real time. See [`crates/gossip-dashboard/`](../gossip-dashboard/).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p gossip-dashboard --example dashboard
|
||||||
|
# or with a TOML config:
|
||||||
|
cargo run -p gossip-dashboard --example dashboard -- crates/gossip-dashboard/examples/sim.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML report
|
||||||
|
|
||||||
|
The `gossip_sim` example runs a simulation and writes a standalone HTML report:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p swactor-gossip --example gossip_sim
|
||||||
|
```
|
||||||
|
|
||||||
|
### Property report
|
||||||
|
|
||||||
|
Runs multiple scenarios and generates an HTML report checking gossip protocol properties (convergence, consistency, load balance):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p swactor-gossip --example gossip_property_report
|
||||||
|
```
|
||||||
568
crates/swactor-gossip/docs/connectome/connectome_dashboard.html
Normal file
568
crates/swactor-gossip/docs/connectome/connectome_dashboard.html
Normal file
|
|
@ -0,0 +1,568 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>swactor — dependency analysis</title>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { background:#1a1a2e; color:#e0e0e0; font-family:system-ui,-apple-system,sans-serif; overflow:hidden; }
|
||||||
|
|
||||||
|
/* ─── Tab bar ───────────────────────────────────────────────────────────── */
|
||||||
|
.tab-bar { display:flex; align-items:center; height:42px; background:#12122a;
|
||||||
|
border-bottom:1px solid #2a2a5a; padding:0 16px; gap:8px; }
|
||||||
|
.tab-bar .title { font-size:14px; font-weight:700; letter-spacing:0.5px; margin-right:18px;
|
||||||
|
color:#8ab4f8; white-space:nowrap; }
|
||||||
|
.tab { background:none; border:none; color:#888; font-size:13px; padding:8px 16px;
|
||||||
|
cursor:pointer; border-bottom:2px solid transparent; transition:color 0.15s; }
|
||||||
|
.tab:hover { color:#ccc; }
|
||||||
|
.tab.active { color:#e0e0e0; border-bottom-color:#4fc3f7; }
|
||||||
|
|
||||||
|
/* ─── Tab content ───────────────────────────────────────────────────────── */
|
||||||
|
.tab-content { display:none; }
|
||||||
|
.tab-content.active { display:block; }
|
||||||
|
|
||||||
|
/* ─── DAG tab ───────────────────────────────────────────────────────────── */
|
||||||
|
#tab-dag { height:calc(100vh - 42px); overflow:hidden; position:relative; }
|
||||||
|
#dag-viewport { width:100%; height:100%; cursor:grab; }
|
||||||
|
#dag-viewport:active { cursor:grabbing; }
|
||||||
|
#dag-viewport svg { display:block; }
|
||||||
|
#dag-controls { position:absolute; top:12px; left:12px; z-index:10;
|
||||||
|
background:rgba(30,30,60,0.9); border-radius:8px; padding:10px 14px;
|
||||||
|
color:#ccc; font-size:13px; backdrop-filter:blur(8px); }
|
||||||
|
#dag-controls button { background:#333; color:#fff; border:1px solid #555;
|
||||||
|
border-radius:4px; padding:4px 10px; cursor:pointer; margin:0 3px; }
|
||||||
|
#dag-controls button:hover { background:#555; }
|
||||||
|
#dag-loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||||
|
color:#ccc; font-size:18px; }
|
||||||
|
|
||||||
|
/* ─── Spectral tab ──────────────────────────────────────────────────────── */
|
||||||
|
#tab-spectral { overflow-y:auto; max-height:calc(100vh - 42px); }
|
||||||
|
|
||||||
|
.grid { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:auto auto;
|
||||||
|
gap:16px; padding:16px 20px 20px; max-width:1600px; margin:0 auto; }
|
||||||
|
|
||||||
|
.panel { background:#16213e; border-radius:10px; border:1px solid #2a2a5a;
|
||||||
|
padding:16px; position:relative; min-height:100px; }
|
||||||
|
.panel h2 { font-size:14px; font-weight:600; margin-bottom:10px; color:#8ab4f8;
|
||||||
|
display:flex; align-items:center; gap:8px; }
|
||||||
|
.panel h2 .icon { font-size:16px; }
|
||||||
|
.panel svg { width:100%; display:block; }
|
||||||
|
|
||||||
|
.tooltip { position:fixed; background:rgba(22,33,62,0.96); border:1px solid #4fc3f7;
|
||||||
|
border-radius:6px; padding:8px 12px; font-size:12px; pointer-events:none;
|
||||||
|
z-index:100; backdrop-filter:blur(8px); max-width:300px;
|
||||||
|
box-shadow:0 4px 20px rgba(0,0,0,0.4); display:none; }
|
||||||
|
.tooltip .tt-label { font-weight:600; color:#4fc3f7; }
|
||||||
|
.tooltip .tt-val { color:#e0e0e0; }
|
||||||
|
|
||||||
|
svg text { user-select:none; }
|
||||||
|
|
||||||
|
/* Metrics panel */
|
||||||
|
.metrics-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 20px; }
|
||||||
|
.metric-item { display:flex; justify-content:space-between; font-size:12px;
|
||||||
|
padding:4px 8px; border-radius:4px; }
|
||||||
|
.metric-item:hover { background:rgba(79,195,247,0.08); }
|
||||||
|
.metric-label { opacity:0.7; }
|
||||||
|
.metric-value { font-weight:600; font-family:'SF Mono',monospace; }
|
||||||
|
.cci-box { grid-column:1/-1; text-align:center; margin-top:10px; padding:14px;
|
||||||
|
border-radius:8px; background:rgba(0,0,0,0.25); border:1px solid #333; }
|
||||||
|
.cci-score { font-size:32px; font-weight:700; }
|
||||||
|
.cci-label { font-size:14px; margin-top:2px; }
|
||||||
|
.cci-desc { font-size:11px; opacity:0.6; margin-top:4px; }
|
||||||
|
|
||||||
|
.sub-header { font-size:11px; font-weight:600; text-transform:uppercase;
|
||||||
|
letter-spacing:1px; opacity:0.4; margin:8px 0 4px; grid-column:1/-1; }
|
||||||
|
|
||||||
|
/* Heatmap */
|
||||||
|
.hm-cell { cursor:pointer; transition:opacity 0.15s; }
|
||||||
|
.hm-cell:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; }
|
||||||
|
|
||||||
|
/* Cohesion / heatmap bars */
|
||||||
|
.fi-bar { cursor:pointer; transition:opacity 0.15s; }
|
||||||
|
.fi-bar:hover { opacity:0.85; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="tab-bar">
|
||||||
|
<div class="title">swactor — dependency analysis</div>
|
||||||
|
<button class="tab active" data-tab="spectral">Spectral Analysis</button>
|
||||||
|
<button class="tab" data-tab="dag">Dependency DAG</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-dag">
|
||||||
|
<div id="dag-controls">
|
||||||
|
<button onclick="zoomIn()">+</button>
|
||||||
|
<button onclick="zoomOut()">−</button>
|
||||||
|
<button onclick="resetView()">fit</button>
|
||||||
|
<span style="margin-left:8px;opacity:0.6">scroll to zoom · drag to pan · click node to focus</span>
|
||||||
|
</div>
|
||||||
|
<div id="dag-viewport"></div>
|
||||||
|
<div id="dag-loading">Loading Graphviz…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="tab-spectral">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="panel" id="panel-structural">
|
||||||
|
<h2><span class="icon">◉</span> Structural Properties</h2>
|
||||||
|
<div id="structural-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="panel-cohesion">
|
||||||
|
<h2><span class="icon">▨</span> Module Cohesion</h2>
|
||||||
|
<svg id="svg-cohesion"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="panel-heatmap">
|
||||||
|
<h2><span class="icon">▦</span> Module Coupling (directed edge counts)</h2>
|
||||||
|
<svg id="svg-heatmap"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="panel-metrics">
|
||||||
|
<h2><span class="icon">∑</span> Complexity Metrics</h2>
|
||||||
|
<div id="metrics-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tooltip" id="tooltip"></div>
|
||||||
|
|
||||||
|
<!-- ─── Script 1: synchronous — data + tab switching + spectral panels ─── -->
|
||||||
|
<script>
|
||||||
|
// ─── Data ──────────────────────────────────────────────────────────────────
|
||||||
|
const DATA = {"structural": {"avg_degree": 1.08, "max_fan_in": 2, "max_fan_in_node": "GossipState", "max_fan_out": 4, "max_fan_out_node": "GossipActor", "dag_depth": 5, "clustering_coeff": 0.1364, "avg_module_cohesion": 0.317, "avg_module_size": 3.0}, "cohesion": [{"module": "protocol", "cohesion": 0.2, "size": 5}, {"module": "trace", "cohesion": 0.25, "size": 5}, {"module": "sim", "cohesion": 0.5, "size": 2}], "coupling": {"modules": ["protocol", "trace", "report", "sim"], "matrix": [[4.0, 2.0, 0.0, 0.0], [1.0, 5.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]]}, "metrics": {"n_nodes": 12, "n_edges": 13, "n_modules": 4, "connected_components": 3, "algebraic_connectivity": 0.0, "normalized_algebraic_connectivity": 0.0, "spectral_entropy": 2.9643, "normalized_spectral_entropy": 0.9351, "edge_density": 0.0985, "cross_module_ratio": 0.2308, "spectral_radius": 2.8046, "normalized_spectral_radius": 0.255, "cci": 0.333, "cci_label": "MODERATE", "cci_color": "#ff9800", "cci_desc": "typical well-structured codebase"}, "module_colors": {"protocol": "#1565c0", "trace": "#c62828", "sim": "#7b1fa2"}};
|
||||||
|
const { structural, cohesion, coupling, metrics, module_colors } = DATA;
|
||||||
|
|
||||||
|
// ─── Tab switching ─────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||||
|
if (btn.dataset.tab === 'dag') {
|
||||||
|
window.dispatchEvent(new Event('dag-visible'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tooltip ───────────────────────────────────────────────────────────────
|
||||||
|
const TT = document.getElementById('tooltip');
|
||||||
|
function showTip(evt, html) {
|
||||||
|
TT.innerHTML = html;
|
||||||
|
TT.style.display = 'block';
|
||||||
|
const x = evt.clientX + 14, y = evt.clientY - 10;
|
||||||
|
TT.style.left = Math.min(x, window.innerWidth - TT.offsetWidth - 20) + 'px';
|
||||||
|
TT.style.top = Math.min(y, window.innerHeight - TT.offsetHeight - 20) + 'px';
|
||||||
|
}
|
||||||
|
function hideTip() { TT.style.display = 'none'; }
|
||||||
|
|
||||||
|
function modColor(mod) { return module_colors[mod] || '#9e9e9e'; }
|
||||||
|
|
||||||
|
// ─── Structural Properties ────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const c = document.getElementById('structural-content');
|
||||||
|
const s = structural;
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="sub-header">Density & Depth</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Edges/node (avg degree)</span><span class="metric-value">${s.avg_degree}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">DAG depth</span><span class="metric-value">${s.dag_depth}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Clustering coefficient</span><span class="metric-value">${s.clustering_coeff}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Avg module size</span><span class="metric-value">${s.avg_module_size}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Dependency Hotspots</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Max fan-in</span><span class="metric-value">${s.max_fan_in} ← ${s.max_fan_in_node}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Max fan-out</span><span class="metric-value">${s.max_fan_out} → ${s.max_fan_out_node}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Cohesion</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Avg module cohesion</span><span class="metric-value">${s.avg_module_cohesion}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(metrics.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Module Cohesion ──────────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const svg = document.getElementById('svg-cohesion');
|
||||||
|
const n = cohesion.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const barH = Math.max(20, Math.min(36, 300/n));
|
||||||
|
const W = 560, H = Math.max(200, n*barH + 60), M = {t:10,r:30,b:30,l:120};
|
||||||
|
const w = W-M.l-M.r, h = H-M.t-M.b;
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||||
|
|
||||||
|
const xScale = v => M.l + v * w;
|
||||||
|
const yScale = i => M.t + (i/n) * h + barH/2;
|
||||||
|
|
||||||
|
// Background grid
|
||||||
|
for (const tick of [0.25, 0.5, 0.75, 1.0]) {
|
||||||
|
const x = xScale(tick);
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||||
|
Object.entries({x1:x,x2:x,y1:M.t,y2:M.t+h,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||||
|
svg.appendChild(line);
|
||||||
|
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
txt.setAttribute('x', x); txt.setAttribute('y', H-8);
|
||||||
|
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#666'); txt.setAttribute('font-size','10');
|
||||||
|
txt.textContent = (tick*100).toFixed(0) + '%';
|
||||||
|
svg.appendChild(txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average line
|
||||||
|
const avgX = xScale(structural.avg_module_cohesion);
|
||||||
|
const avgLine = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||||
|
Object.entries({x1:avgX,x2:avgX,y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>avgLine.setAttribute(k,v));
|
||||||
|
svg.appendChild(avgLine);
|
||||||
|
const avgLbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
avgLbl.setAttribute('x', avgX+4); avgLbl.setAttribute('y', M.t+10);
|
||||||
|
avgLbl.setAttribute('fill','#ff4444'); avgLbl.setAttribute('font-size','9'); avgLbl.setAttribute('opacity','0.8');
|
||||||
|
avgLbl.textContent = 'avg';
|
||||||
|
svg.appendChild(avgLbl);
|
||||||
|
|
||||||
|
cohesion.forEach((d, i) => {
|
||||||
|
const barW = Math.max(d.cohesion * w, 2);
|
||||||
|
const y = yScale(i) - barH*0.35;
|
||||||
|
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||||
|
rect.setAttribute('x', M.l); rect.setAttribute('y', y);
|
||||||
|
rect.setAttribute('width', barW); rect.setAttribute('height', barH*0.7);
|
||||||
|
rect.setAttribute('rx', 3);
|
||||||
|
rect.setAttribute('fill', modColor(d.module));
|
||||||
|
rect.setAttribute('opacity', 0.85);
|
||||||
|
rect.classList.add('fi-bar');
|
||||||
|
rect.addEventListener('mousemove', e => showTip(e,
|
||||||
|
`<span class="tt-label">${d.module}</span><br>` +
|
||||||
|
`Types: <span class="tt-val">${d.size}</span><br>` +
|
||||||
|
`Cohesion: <span class="tt-val">${(d.cohesion*100).toFixed(1)}%</span>`
|
||||||
|
));
|
||||||
|
rect.addEventListener('mouseleave', hideTip);
|
||||||
|
svg.appendChild(rect);
|
||||||
|
|
||||||
|
// Value label on bar
|
||||||
|
const valTxt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
valTxt.setAttribute('x', M.l + barW + 6); valTxt.setAttribute('y', yScale(i)+4);
|
||||||
|
valTxt.setAttribute('fill','#ccc'); valTxt.setAttribute('font-size','10'); valTxt.setAttribute('font-weight','600');
|
||||||
|
valTxt.textContent = (d.cohesion*100).toFixed(0) + '%';
|
||||||
|
svg.appendChild(valTxt);
|
||||||
|
|
||||||
|
// Module label
|
||||||
|
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
txt.setAttribute('x', M.l-8); txt.setAttribute('y', yScale(i)+4);
|
||||||
|
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill', modColor(d.module));
|
||||||
|
txt.setAttribute('font-size','11'); txt.setAttribute('font-weight','600');
|
||||||
|
txt.textContent = `${d.module} (${d.size})`;
|
||||||
|
svg.appendChild(txt);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Module Coupling Heatmap ───────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const mods = coupling.modules;
|
||||||
|
const mat = coupling.matrix;
|
||||||
|
const n = mods.length;
|
||||||
|
const svg = document.getElementById('svg-heatmap');
|
||||||
|
const cellSz = Math.min(55, 400/n);
|
||||||
|
const M = {t:10,r:60,b:80,l:100};
|
||||||
|
const W = M.l + n*cellSz + M.r, H = M.t + n*cellSz + M.b;
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||||
|
|
||||||
|
const maxVal = Math.max(...mat.flat(), 1);
|
||||||
|
|
||||||
|
// Color scale: 0=transparent dark, max=deep red
|
||||||
|
function heatColor(v) {
|
||||||
|
if (v === 0) return '#1a1a2e';
|
||||||
|
const t = v / maxVal;
|
||||||
|
const r = Math.round(40 + 215*t);
|
||||||
|
const g = Math.round(30 + 40*(1-t));
|
||||||
|
const b = Math.round(50*(1-t));
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// Row labels
|
||||||
|
const rl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
rl.setAttribute('x', M.l-8); rl.setAttribute('y', M.t + i*cellSz + cellSz/2 + 4);
|
||||||
|
rl.setAttribute('text-anchor','end'); rl.setAttribute('fill', modColor(mods[i]));
|
||||||
|
rl.setAttribute('font-size','11'); rl.setAttribute('font-weight','600');
|
||||||
|
rl.textContent = mods[i];
|
||||||
|
svg.appendChild(rl);
|
||||||
|
|
||||||
|
// Column labels
|
||||||
|
const cl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
cl.setAttribute('x', M.l + i*cellSz + cellSz/2);
|
||||||
|
cl.setAttribute('y', M.t + n*cellSz + 16);
|
||||||
|
cl.setAttribute('text-anchor','end'); cl.setAttribute('fill', modColor(mods[i]));
|
||||||
|
cl.setAttribute('font-size','11'); cl.setAttribute('font-weight','600');
|
||||||
|
cl.setAttribute('transform', `rotate(-45, ${M.l + i*cellSz + cellSz/2}, ${M.t + n*cellSz + 16})`);
|
||||||
|
cl.textContent = mods[i];
|
||||||
|
svg.appendChild(cl);
|
||||||
|
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const v = mat[i][j];
|
||||||
|
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||||
|
rect.setAttribute('x', M.l + j*cellSz + 1);
|
||||||
|
rect.setAttribute('y', M.t + i*cellSz + 1);
|
||||||
|
rect.setAttribute('width', cellSz-2); rect.setAttribute('height', cellSz-2);
|
||||||
|
rect.setAttribute('rx', 3);
|
||||||
|
rect.setAttribute('fill', heatColor(v));
|
||||||
|
rect.classList.add('hm-cell');
|
||||||
|
rect.addEventListener('mousemove', e => showTip(e,
|
||||||
|
`<span class="tt-label">${mods[i]} → ${mods[j]}</span><br>` +
|
||||||
|
`Edges: <span class="tt-val">${v}</span>` +
|
||||||
|
(i !== j ? '<br><span style="opacity:0.6">cross-module</span>' : '<br><span style="opacity:0.6">intra-module</span>')
|
||||||
|
));
|
||||||
|
rect.addEventListener('mouseleave', hideTip);
|
||||||
|
svg.appendChild(rect);
|
||||||
|
|
||||||
|
// Cell text
|
||||||
|
if (v > 0) {
|
||||||
|
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
txt.setAttribute('x', M.l + j*cellSz + cellSz/2);
|
||||||
|
txt.setAttribute('y', M.t + i*cellSz + cellSz/2 + 4);
|
||||||
|
txt.setAttribute('text-anchor','middle'); txt.setAttribute('font-size','11');
|
||||||
|
txt.setAttribute('font-weight','700'); txt.setAttribute('pointer-events','none');
|
||||||
|
txt.setAttribute('fill', v > maxVal*0.5 ? '#fff' : '#ccc');
|
||||||
|
txt.textContent = v;
|
||||||
|
svg.appendChild(txt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels
|
||||||
|
const srcL = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
srcL.setAttribute('x', 10); srcL.setAttribute('y', M.t + n*cellSz/2);
|
||||||
|
srcL.setAttribute('text-anchor','middle'); srcL.setAttribute('fill','#666');
|
||||||
|
srcL.setAttribute('font-size','10');
|
||||||
|
srcL.setAttribute('transform', `rotate(-90,10,${M.t + n*cellSz/2})`);
|
||||||
|
srcL.textContent = 'source module';
|
||||||
|
svg.appendChild(srcL);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Metrics Panel ─────────────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const c = document.getElementById('metrics-content');
|
||||||
|
const mm = metrics;
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="sub-header">Graph</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Nodes</span><span class="metric-value">${mm.n_nodes}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Directed edges</span><span class="metric-value">${mm.n_edges}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Modules</span><span class="metric-value">${mm.n_modules}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Components</span><span class="metric-value">${mm.connected_components}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Spectral</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">λ<sub>2</sub> (alg. connectivity)</span><span class="metric-value">${mm.algebraic_connectivity}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">λ<sub>2</sub>/n (normalized)</span><span class="metric-value">${mm.normalized_algebraic_connectivity}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Spectral entropy</span><span class="metric-value">${mm.spectral_entropy}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Norm. entropy</span><span class="metric-value">${mm.normalized_spectral_entropy}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Spectral radius</span><span class="metric-value">${mm.spectral_radius}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Norm. radius</span><span class="metric-value">${mm.normalized_spectral_radius}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Coupling</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Edge density</span><span class="metric-value">${mm.edge_density}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(mm.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||||
|
|
||||||
|
<div class="cci-box">
|
||||||
|
<div class="cci-score" style="color:${mm.cci_color}">CCI = ${mm.cci}</div>
|
||||||
|
<div class="cci-label" style="color:${mm.cci_color}">${mm.cci_label}</div>
|
||||||
|
<div class="cci-desc">${mm.cci_desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Script 2: module — viz-js DAG rendering (async) ─────────────────── -->
|
||||||
|
<script type="module">
|
||||||
|
import { instance } from 'https://cdn.jsdelivr.net/npm/@viz-js/viz@3.11.0/lib/viz-standalone.mjs';
|
||||||
|
|
||||||
|
const DOT_SOURCE = `digraph swactor {
|
||||||
|
rankdir=LR;
|
||||||
|
fontname="Helvetica";
|
||||||
|
fontsize=14;
|
||||||
|
node [fontname="Helvetica", fontsize=11, style=filled, shape=record];
|
||||||
|
edge [fontname="Helvetica", fontsize=9];
|
||||||
|
label="swactor — internal dependency DAG";
|
||||||
|
labelloc=t;
|
||||||
|
compound=true;
|
||||||
|
newrank=true;
|
||||||
|
splines=ortho;
|
||||||
|
|
||||||
|
subgraph cluster_protocol {
|
||||||
|
label="protocol";
|
||||||
|
style="rounded,filled"; fillcolor="#e3f2fd"; color="#1565c0";
|
||||||
|
VersionedValue [label="{VersionedValue|value: Vec\\<u8\\>\\nversion: u64}", fillcolor="#bbdefb"];
|
||||||
|
GossipState [label="{GossipState|entries: HashMap\\<String, VersionedValue\\>}", fillcolor="#bbdefb"];
|
||||||
|
GossipQueryResponse [label="{GossipQueryResponse|key: String\\nvalue: Option\\<Vec\\<u8\\>\\>\\nversion: Option\\<u64\\>}", fillcolor="#bbdefb"];
|
||||||
|
GossipMessage [label="{«enum» GossipMessage|AddPeer (ActorAddress)\\nRemovePeer (ActorAddress)\\nSet \\{ key: String, value: Vec\\<u8\\> \\}\\nDoGossipRound\\nPush \\{ from: ActorAddress, state: GossipState \\}\\nQuery \\{ key: String, reply_to: ActorAddress \\}\\nTakeSnapshot}", fillcolor="#bbdefb"];
|
||||||
|
GossipActor [label="{GossipActor|state: GossipState\\npeers: Vec\\<ActorAddress\\>\\ntrace: Option\\<TraceContext\\>}", fillcolor="#bbdefb"];
|
||||||
|
}
|
||||||
|
subgraph cluster_trace {
|
||||||
|
label="trace";
|
||||||
|
style="rounded,filled"; fillcolor="#fce4ec"; color="#c62828";
|
||||||
|
TraceContext [label="{TraceContext|event_log: EventLog\\ntick_counter: TickCounter\\nname_registry: NameRegistry}", fillcolor="#ffcdd2"];
|
||||||
|
GossipEvent [label="{GossipEvent|tick: u64\\nnode_name: String\\nnode_addr: ActorAddress\\nkind: GossipEventKind}", fillcolor="#ffcdd2"];
|
||||||
|
GossipEventKind [label="{«enum» GossipEventKind|LocalSet \\{ key: String \\}\\nGossipRoundStarted \\{ target_name: String \\}\\nGossipRoundNoPeers\\nPushReceived \\{ from_name: String, keys_updated: usize \\}\\nQueryReceived \\{ key: String \\}\\nPeerAdded \\{ peer_name: String \\}\\nPeerRemoved \\{ peer_name: String \\}\\nStateSnapshot \\{ snapshot: NodeSnapshot \\}}", fillcolor="#ffcdd2"];
|
||||||
|
NodeSnapshot [label="{NodeSnapshot|entries: HashMap\\<String, VersionedValue\\>\\npeer_count: usize}", fillcolor="#ffcdd2"];
|
||||||
|
SimulationTrace [label="{SimulationTrace|name: String\\nnode_names: Vec\\<String\\>\\nnode_addrs: Vec\\<ActorAddress\\>\\ntopology_edges: Vec\\<(String, String)\\>\\nevents: Vec\\<GossipEvent\\>\\nsnapshots_per_round: Vec\\<Vec\\<(String, NodeSnapshot)\\>\\>\\nnum_rounds: usize\\ntotal_keys: usize}", fillcolor="#ffcdd2"];
|
||||||
|
}
|
||||||
|
subgraph cluster_report {
|
||||||
|
label="report";
|
||||||
|
style="rounded,filled"; fillcolor="#fff3e0"; color="#e65100";
|
||||||
|
}
|
||||||
|
subgraph cluster_sim {
|
||||||
|
label="sim";
|
||||||
|
style="rounded,filled"; fillcolor="#f3e5f5"; color="#7b1fa2";
|
||||||
|
Topology [label="{«enum» Topology|Ring\\nStar\\nFullMesh\\nChain\\nPartitioned}", fillcolor="#e1bee7"];
|
||||||
|
SimConfig [label="{SimConfig|name: String\\ntopology: Topology\\nnum_nodes: usize\\ninitial_data: Vec\\<(String, Vec\\<u8\\>)\\>\\nnum_rounds: usize\\nticks_per_round: usize\\nheal_after_round: Option\\<usize\\>}", fillcolor="#e1bee7"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// INTRA-MODULE EDGES (within same cluster)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
GossipState -> VersionedValue [label="entries", style=dashed, color="#1565c0", penwidth=1];
|
||||||
|
GossipMessage -> GossipState [label="Push", style=dashed, color="#1565c0", penwidth=1];
|
||||||
|
GossipActor -> GossipState [label="state", style=dashed, color="#1565c0", penwidth=1];
|
||||||
|
GossipActor -> GossipMessage [label="handle() param", style=dashed, color="#1565c0", penwidth=1];
|
||||||
|
GossipEvent -> GossipEventKind [label="kind", style=dashed, color="#c62828", penwidth=1];
|
||||||
|
GossipEventKind -> NodeSnapshot [label="StateSnapshot", style=dashed, color="#c62828", penwidth=1];
|
||||||
|
SimulationTrace -> GossipEvent [label="events", style=dashed, color="#c62828", penwidth=1];
|
||||||
|
SimulationTrace -> NodeSnapshot [label="snapshots_per_round", style=dashed, color="#c62828", penwidth=1];
|
||||||
|
TraceContext -> GossipEvent [label="record_event() param", style=dashed, color="#c62828", penwidth=1];
|
||||||
|
SimConfig -> Topology [label="topology", style=dashed, color="#7b1fa2", penwidth=1];
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// CROSS-MODULE EDGES (the real dependency DAG)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// --- protocol depends on trace ---
|
||||||
|
GossipActor -> TraceContext [label="trace", style=solid, color="#1565c0", penwidth=1.5];
|
||||||
|
GossipActor -> GossipEventKind [label="record() param", style=solid, color="#1565c0", penwidth=1.5];
|
||||||
|
|
||||||
|
// --- trace depends on protocol ---
|
||||||
|
NodeSnapshot -> VersionedValue [label="entries", style=solid, color="#c62828", penwidth=1.5];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const viz = await instance();
|
||||||
|
const svg = viz.renderSVGElement(DOT_SOURCE);
|
||||||
|
document.getElementById('dag-loading').remove();
|
||||||
|
|
||||||
|
const vp = document.getElementById('dag-viewport');
|
||||||
|
vp.appendChild(svg);
|
||||||
|
|
||||||
|
// ─── Dark-mode SVG recoloring ──────────────────────────────────────────────
|
||||||
|
svg.querySelectorAll('polygon[fill="white"]').forEach(el => el.setAttribute('fill','#1a1a2e'));
|
||||||
|
svg.querySelectorAll('.graph > text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
||||||
|
svg.querySelectorAll('.cluster > text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||||
|
svg.querySelectorAll('.edge text').forEach(el => el.setAttribute('fill','#ffb74d'));
|
||||||
|
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||||
|
|
||||||
|
// ─── Click-to-focus ────────────────────────────────────────────────────────
|
||||||
|
const edges = svg.querySelectorAll('.edge');
|
||||||
|
const nodes = svg.querySelectorAll('.node');
|
||||||
|
const clusterChrome = [];
|
||||||
|
svg.querySelectorAll('.cluster').forEach(c => {
|
||||||
|
c.querySelectorAll(':scope > path, :scope > polygon, :scope > text').forEach(el => clusterChrome.push(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeByTitle = new Map();
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const t = n.querySelector('title');
|
||||||
|
if (t) nodeByTitle.set(t.textContent.trim(), n);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeToClusterEls = new Map();
|
||||||
|
svg.querySelectorAll('.cluster').forEach(cluster => {
|
||||||
|
const chrome = [...cluster.querySelectorAll(':scope > path, :scope > polygon, :scope > text')];
|
||||||
|
cluster.querySelectorAll('.node title').forEach(t => {
|
||||||
|
nodeToClusterEls.set(t.textContent.trim(), chrome);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const adj = new Map();
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const t = edge.querySelector('title');
|
||||||
|
if (!t) return;
|
||||||
|
const parts = t.textContent.trim().split('->').map(s => s.trim());
|
||||||
|
if (parts.length !== 2) return;
|
||||||
|
const [src, dst] = parts;
|
||||||
|
if (!adj.has(src)) adj.set(src, { edges: [], neighbors: new Set() });
|
||||||
|
if (!adj.has(dst)) adj.set(dst, { edges: [], neighbors: new Set() });
|
||||||
|
adj.get(src).edges.push(edge);
|
||||||
|
adj.get(src).neighbors.add(dst);
|
||||||
|
adj.get(dst).edges.push(edge);
|
||||||
|
adj.get(dst).neighbors.add(src);
|
||||||
|
});
|
||||||
|
|
||||||
|
const DIM = 0.08;
|
||||||
|
let focused = null;
|
||||||
|
|
||||||
|
function clearFocus() {
|
||||||
|
focused = null;
|
||||||
|
nodes.forEach(n => n.style.opacity = '');
|
||||||
|
edges.forEach(e => e.style.opacity = '');
|
||||||
|
clusterChrome.forEach(el => el.style.opacity = '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNode(title) {
|
||||||
|
if (focused === title) { clearFocus(); return; }
|
||||||
|
focused = title;
|
||||||
|
const info = adj.get(title) || { edges: [], neighbors: new Set() };
|
||||||
|
const connected = new Set([title, ...info.neighbors]);
|
||||||
|
|
||||||
|
nodes.forEach(n => n.style.opacity = DIM);
|
||||||
|
edges.forEach(e => e.style.opacity = DIM);
|
||||||
|
clusterChrome.forEach(el => el.style.opacity = DIM);
|
||||||
|
|
||||||
|
connected.forEach(name => {
|
||||||
|
const el = nodeByTitle.get(name);
|
||||||
|
if (el) el.style.opacity = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info.edges.forEach(e => e.style.opacity = 1);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
connected.forEach(name => {
|
||||||
|
const chrome = nodeToClusterEls.get(name);
|
||||||
|
if (chrome) chrome.forEach(el => {
|
||||||
|
if (!seen.has(el)) { seen.add(el); el.style.opacity = 1; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
node.style.cursor = 'pointer';
|
||||||
|
node.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const t = node.querySelector('title');
|
||||||
|
if (t) focusNode(t.textContent.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Pan & zoom ────────────────────────────────────────────────────────────
|
||||||
|
let scale = 1, tx = 0, ty = 0, dragging = false, didDrag = false, sx = 0, sy = 0;
|
||||||
|
function applyTransform() { svg.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`; svg.style.transformOrigin = '0 0'; }
|
||||||
|
|
||||||
|
window.resetView = function() {
|
||||||
|
const vw = vp.clientWidth, vh = vp.clientHeight;
|
||||||
|
const bb = svg.getBBox();
|
||||||
|
scale = Math.min(vw / bb.width, vh / bb.height) * 0.92;
|
||||||
|
tx = (vw - bb.width * scale) / 2;
|
||||||
|
ty = (vh - bb.height * scale) / 2;
|
||||||
|
applyTransform();
|
||||||
|
};
|
||||||
|
let dagFitted = false;
|
||||||
|
window.addEventListener('dag-visible', () => {
|
||||||
|
if (!dagFitted) { dagFitted = true; requestAnimationFrame(resetView); }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.zoomIn = function() { scale *= 1.3; applyTransform(); };
|
||||||
|
window.zoomOut = function() { scale *= 0.7; applyTransform(); };
|
||||||
|
|
||||||
|
vp.addEventListener('wheel', e => { e.preventDefault(); const f = e.deltaY < 0 ? 1.12 : 0.89; const rect = vp.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; tx = mx - f * (mx - tx); ty = my - f * (my - ty); scale *= f; applyTransform(); }, { passive:false });
|
||||||
|
vp.addEventListener('pointerdown', e => { dragging=true; didDrag=false; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); });
|
||||||
|
vp.addEventListener('pointermove', e => { if(!dragging) return; didDrag=true; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); });
|
||||||
|
vp.addEventListener('pointerup', () => dragging=false);
|
||||||
|
vp.addEventListener('click', e => { if (!didDrag && !e.target.closest('.node')) clearFocus(); });
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
{
|
||||||
|
"graph": {
|
||||||
|
"n_nodes": 12,
|
||||||
|
"n_edges": 13,
|
||||||
|
"n_modules": 4,
|
||||||
|
"connected_components": 3,
|
||||||
|
"modules": [
|
||||||
|
"protocol",
|
||||||
|
"trace",
|
||||||
|
"report",
|
||||||
|
"sim"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structural": {
|
||||||
|
"avg_degree": 1.0833333333333333,
|
||||||
|
"max_fan_in": {
|
||||||
|
"count": 2,
|
||||||
|
"node": "GossipState"
|
||||||
|
},
|
||||||
|
"max_fan_out": {
|
||||||
|
"count": 4,
|
||||||
|
"node": "GossipActor"
|
||||||
|
},
|
||||||
|
"dag_depth": 5,
|
||||||
|
"clustering_coefficient": 0.13636363636363635,
|
||||||
|
"avg_module_size": 3.0
|
||||||
|
},
|
||||||
|
"module_coupling": {
|
||||||
|
"module_names": [
|
||||||
|
"protocol",
|
||||||
|
"trace",
|
||||||
|
"report",
|
||||||
|
"sim"
|
||||||
|
],
|
||||||
|
"coupling_matrix": [
|
||||||
|
[
|
||||||
|
4.0,
|
||||||
|
2.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.0,
|
||||||
|
5.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"cross_module_edges": 3,
|
||||||
|
"total_edges": 13
|
||||||
|
},
|
||||||
|
"module_cohesion": {
|
||||||
|
"protocol": 0.2,
|
||||||
|
"trace": 0.25,
|
||||||
|
"report": null,
|
||||||
|
"sim": 0.5
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"algebraic_connectivity": 0.0,
|
||||||
|
"spectral_entropy": 2.9642609519436975,
|
||||||
|
"edge_density": 0.09848484848484848,
|
||||||
|
"cross_module_ratio": 0.23076923076923078,
|
||||||
|
"spectral_radius": 2.8045993435494494,
|
||||||
|
"avg_module_cohesion": 0.31666666666666665,
|
||||||
|
"cci": 0.3329511639209368
|
||||||
|
}
|
||||||
|
}
|
||||||
56
crates/swactor-gossip/docs/connectome/connectome_report.txt
Normal file
56
crates/swactor-gossip/docs/connectome/connectome_report.txt
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
========================================================================
|
||||||
|
SPECTRAL ANALYSIS REPORT — Dependency DAG
|
||||||
|
========================================================================
|
||||||
|
|
||||||
|
GRAPH SUMMARY
|
||||||
|
----------------------------------------
|
||||||
|
Nodes: 12
|
||||||
|
Directed edges: 13
|
||||||
|
Modules: 4
|
||||||
|
Connected components: 3
|
||||||
|
Modules: protocol, trace, report, sim
|
||||||
|
|
||||||
|
STRUCTURAL PROPERTIES
|
||||||
|
----------------------------------------
|
||||||
|
Edges/node (avg degree): 1.08
|
||||||
|
Max fan-in: 2 (GossipState)
|
||||||
|
Max fan-out: 4 (GossipActor)
|
||||||
|
DAG depth: 5
|
||||||
|
Clustering coefficient: 0.1364
|
||||||
|
|
||||||
|
MODULE COHESION
|
||||||
|
----------------------------------------
|
||||||
|
Module Size Cohesion
|
||||||
|
protocol 5 0.200
|
||||||
|
trace 5 0.250
|
||||||
|
report 0 —
|
||||||
|
sim 2 0.500
|
||||||
|
────────────────────────────────
|
||||||
|
Average cohesion: 0.317
|
||||||
|
Avg module size: 3.0
|
||||||
|
|
||||||
|
MODULE COUPLING MATRIX (directed edge counts)
|
||||||
|
----------------------------------------
|
||||||
|
protocol trace report sim
|
||||||
|
protocol 4 2 0 0
|
||||||
|
trace 1 5 0 0
|
||||||
|
report 0 0 0 0
|
||||||
|
sim 0 0 0 1
|
||||||
|
|
||||||
|
Cross-module edges: 3 / 13 (23.1%)
|
||||||
|
|
||||||
|
CONNECTOME COMPLEXITY INDEX (CCI)
|
||||||
|
----------------------------------------
|
||||||
|
Sub-metric Raw Normalized Weight Contrib
|
||||||
|
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||||
|
Algebraic connectivity (lambda_2/n) 0.0000 0.0000 0.25 0.0000
|
||||||
|
Spectral entropy (H/log2(k)) 2.9643 0.9351 0.25 0.2338
|
||||||
|
Edge density (|E|/n(n-1)) 0.0985 0.0985 0.15 0.0148
|
||||||
|
Cross-module coupling ratio 0.2308 0.2308 0.20 0.0462
|
||||||
|
Spectral radius (rho/(n-1)) 2.8046 0.2550 0.15 0.0382
|
||||||
|
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||||
|
CCI (weighted sum) 1.00 0.3330
|
||||||
|
|
||||||
|
Interpretation: MODERATE complexity — typical well-structured codebase
|
||||||
|
|
||||||
|
========================================================================
|
||||||
513
crates/swactor-gossip/examples/gossip_property_report.rs
Normal file
513
crates/swactor-gossip/examples/gossip_property_report.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use swactor_gossip::properties::*;
|
||||||
|
use swactor_gossip::property_report::*;
|
||||||
|
use swactor_gossip::sim::{run_simulation, SimConfig, Topology};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("=== Gossip Protocol Property Verification Report ===\n");
|
||||||
|
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
let mut convergence_overlays = Vec::new();
|
||||||
|
let mut scaling_points_st = Vec::new();
|
||||||
|
let mut scaling_points_mt = Vec::new();
|
||||||
|
let mut thread_comparisons = Vec::new();
|
||||||
|
|
||||||
|
// ── Section 1: Reliability ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[1/12] Reliability...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("FullMesh 100", Topology::FullMesh, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
convergence_overlays.push(("FullMesh 100".into(), metrics.convergence_curve.clone()));
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes, 5 keys, 30 rounds".into(),
|
||||||
|
description: "Single-threaded full-mesh topology".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_delivery_ratio(&metrics, 1.0),
|
||||||
|
check_atomic_delivery(&metrics),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("Ring 100", Topology::Ring, 100, 5, 120, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
convergence_overlays.push(("Ring 100".into(), metrics.convergence_curve.clone()));
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Ring 100 nodes, 5 keys, 120 rounds".into(),
|
||||||
|
description: "Single-threaded ring topology (needs ~N rounds)".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_delivery_ratio(&metrics, 1.0)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Reliability".into(),
|
||||||
|
explanation: "Verifies that all nodes eventually receive all keys. Delivery ratio should be 1.0 and delivery should be atomic per key.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 2: Latency ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[2/12] Latency...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let n = 100;
|
||||||
|
let bound = 4 * ((n as f64).ln().ceil() as usize);
|
||||||
|
let config = base_config_with("FullMesh Latency", Topology::FullMesh, n, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes - O(log N) convergence".into(),
|
||||||
|
description: format!("Should converge within 4*ln(N) = {} rounds", bound),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_convergence_bound(&metrics, bound),
|
||||||
|
check_last_node_latency(&metrics, 5),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("Ring Latency", Topology::Ring, 200, 5, 220, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Ring 200 nodes - O(N) convergence".into(),
|
||||||
|
description: "Ring should converge within N rounds".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_convergence_bound(&metrics, 200)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Latency".into(),
|
||||||
|
explanation: "Measures convergence speed across topologies. FullMesh converges in O(log N), ring in O(N).".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 3: Message Complexity ───────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[3/12] Message Complexity...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("Ring MsgCount", Topology::Ring, 500, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Ring 500 nodes, 30 rounds".into(),
|
||||||
|
description: "Each node sends exactly 1 push per round in ring".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_total_pushes_eq(&metrics, 500 * 30),
|
||||||
|
check_redundancy_above(&metrics, 0.0),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("FullMesh Redundancy", Topology::FullMesh, 100, 5, 50, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes, 50 rounds".into(),
|
||||||
|
description: "After convergence, most pushes are redundant".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_redundancy_above(&metrics, 0.3)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Message Complexity".into(),
|
||||||
|
explanation: "Analyzes message overhead: total pushes, useful vs redundant, and per-topology efficiency.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 4: Bandwidth/Load ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[4/12] Bandwidth/Load...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("Star Hub", Topology::Star, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Star 100 nodes - hub hotspot".into(),
|
||||||
|
description: "Hub node-0 should receive the most pushes".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_hub_is_hotspot(&metrics, "node-0")],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("Ring Load", Topology::Ring, 500, 5, 60, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Ring 500 nodes - load balance".into(),
|
||||||
|
description: "Ring should distribute load evenly across nodes".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_load_balance_cv(&metrics, 0.3)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Bandwidth/Load".into(),
|
||||||
|
explanation: "Examines how push traffic is distributed across nodes. Star topologies create hotspots at the hub.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 5: Convergence ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[5/12] Convergence...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("FullMesh Conv", Topology::FullMesh, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes - convergence curve".into(),
|
||||||
|
description: "Convergence curve should be monotonic with zero residue".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_curve_monotonic(&metrics),
|
||||||
|
check_curve_s_shape(&metrics),
|
||||||
|
check_zero_residue(&metrics),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Convergence".into(),
|
||||||
|
explanation: "Verifies convergence curve properties: monotonicity, S-shape for dense topologies, and zero residue.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 6: Fault Tolerance ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[6/12] Fault Tolerance...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let mut config = base_config_with("Partition NoHeal", Topology::Partitioned, 100, 5, 40, 1);
|
||||||
|
config.heal_after_round = None;
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Partitioned 100 nodes, no heal".into(),
|
||||||
|
description: "Partitioned network cannot fully converge".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_partition_no_converge(&metrics)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut config = base_config_with("Partition Heal", Topology::Partitioned, 100, 5, 300, 1);
|
||||||
|
config.heal_after_round = Some(100);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Partitioned 100 nodes, heal at round 100".into(),
|
||||||
|
description: "After healing, full convergence should be achieved".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_partition_heals(&metrics),
|
||||||
|
check_partial_before_heal(&metrics, 100),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Fault Tolerance".into(),
|
||||||
|
explanation: "Tests behavior under network partitions and recovery after healing.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 7: Push Protocol ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[7/12] Push Protocol...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("Push Proto", Topology::Ring, 500, 5, 10, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Ring 500 nodes, 10 rounds".into(),
|
||||||
|
description: "One push per node per round".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_one_push_per_node_per_round(&metrics, 10)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("No Push Chain", Topology::Chain, 100, 1, 20, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
let last_node = format!("node-{}", 99);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Chain 100 nodes - last node".into(),
|
||||||
|
description: "Last node in chain has no peers, should never push".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_no_push_without_peers(&trace, &last_node)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Push Protocol".into(),
|
||||||
|
explanation: "Verifies the push protocol mechanics: exactly one push per node per round, no pushes without peers.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 8: Peer Selection ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[8/12] Peer Selection...");
|
||||||
|
let config = base_config_with("Peer Select", Topology::Star, 10, 1, 500, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Peer Selection".into(),
|
||||||
|
explanation: "Verifies that peer selection is approximately uniform using chi-squared test.".into(),
|
||||||
|
scenarios: vec![ScenarioReport {
|
||||||
|
name: "Star 10 nodes, 500 rounds".into(),
|
||||||
|
description: "Node-0 has 9 peers, should select each approximately uniformly".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_peer_selection_uniform(&metrics, 26.12)],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 9: Topology Impact ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[9/12] Topology Impact...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let topos = [
|
||||||
|
("FullMesh", Topology::FullMesh, 30),
|
||||||
|
("Star", Topology::Star, 40),
|
||||||
|
("Ring", Topology::Ring, 120),
|
||||||
|
("Chain", Topology::Chain, 120),
|
||||||
|
];
|
||||||
|
for (name, topo, rounds) in &topos {
|
||||||
|
let config = base_config_with(
|
||||||
|
&format!("Topo-{name}"),
|
||||||
|
topo.clone(),
|
||||||
|
100,
|
||||||
|
5,
|
||||||
|
*rounds,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: format!("{name} 100 nodes"),
|
||||||
|
description: format!("Convergence round: {:?}", metrics.convergence_round),
|
||||||
|
metrics,
|
||||||
|
results: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Topology Impact".into(),
|
||||||
|
explanation: "Compares convergence speed and efficiency across topologies. Denser topologies converge faster but with more redundancy.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 10: Consistency ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[10/12] Consistency...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("LWW FullMesh", Topology::FullMesh, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes - LWW consistency".into(),
|
||||||
|
description: "All keys should have exactly 1 distinct final value".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_lww_single_value(&metrics),
|
||||||
|
check_no_stale_reads(&metrics),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = base_config_with("Entropy FullMesh", Topology::FullMesh, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes - entropy".into(),
|
||||||
|
description: "Entropy should reach zero at convergence".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_entropy_zero_at_convergence(&metrics)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Consistency".into(),
|
||||||
|
explanation: "Verifies LWW consistency: single final value per key, monotonically decreasing entropy, no stale reads post-convergence.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 11: Practical ───────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[11/12] Practical...");
|
||||||
|
let config = base_config_with("State Size", Topology::FullMesh, 100, 5, 30, 1);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Practical".into(),
|
||||||
|
explanation: "Verifies practical properties: state size stabilizes at key count and grows monotonically.".into(),
|
||||||
|
scenarios: vec![ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes, 5 keys".into(),
|
||||||
|
description: "State size should stabilize at 5.0 and never decrease".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_state_size_stabilizes(&metrics, 5.0),
|
||||||
|
check_state_size_monotonic(&metrics),
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 12: Multi-threaded ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("[12/12] Multi-threaded scenarios...");
|
||||||
|
let mut scenarios = Vec::new();
|
||||||
|
|
||||||
|
let config = base_config_with("FullMesh MT", Topology::FullMesh, 100, 5, 30, 4);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes, 4 threads".into(),
|
||||||
|
description: "Multi-threaded full-mesh should still converge".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_delivery_ratio(&metrics, 1.0),
|
||||||
|
check_curve_monotonic(&metrics),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let n = 100;
|
||||||
|
let bound = 2 * 4 * ((n as f64).ln().ceil() as usize);
|
||||||
|
let config = base_config_with("FullMesh MT Latency", Topology::FullMesh, n, 5, 30, 4);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "FullMesh 100 nodes, 4 threads - latency".into(),
|
||||||
|
description: format!("Multi-threaded fullmesh, bound = {}", bound),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![
|
||||||
|
check_delivery_ratio(&metrics, 1.0),
|
||||||
|
check_convergence_bound(&metrics, bound),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut config = base_config_with("Partition Heal MT", Topology::Partitioned, 100, 5, 300, 4);
|
||||||
|
config.heal_after_round = Some(100);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scenarios.push(ScenarioReport {
|
||||||
|
name: "Partition heal, 4 threads".into(),
|
||||||
|
description: "Multi-threaded partition healing".into(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
results: vec![check_partition_heals(&metrics)],
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(ReportSection {
|
||||||
|
title: "Multi-threaded".into(),
|
||||||
|
explanation: "Verifies that gossip properties hold under concurrent multi-threaded scheduling with non-deterministic message ordering.".into(),
|
||||||
|
scenarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scaling series (single-threaded) ────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("Scaling series (single-threaded)...");
|
||||||
|
for &n in &[50, 100, 200, 500] {
|
||||||
|
let rounds = 60; // FullMesh converges in O(log N)
|
||||||
|
let config = base_config_with(
|
||||||
|
&format!("Scale ST N={n}"),
|
||||||
|
Topology::FullMesh,
|
||||||
|
n,
|
||||||
|
5,
|
||||||
|
rounds,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scaling_points_st.push(ScalingPoint {
|
||||||
|
n,
|
||||||
|
convergence_round: metrics.convergence_round,
|
||||||
|
total_pushes: metrics.total_pushes,
|
||||||
|
label: format!("ST N={n}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scaling series (multi-threaded) ─────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("Scaling series (multi-threaded)...");
|
||||||
|
for &n in &[50, 100, 200, 500] {
|
||||||
|
let rounds = 60;
|
||||||
|
let config = base_config_with(
|
||||||
|
&format!("Scale MT N={n}"),
|
||||||
|
Topology::FullMesh,
|
||||||
|
n,
|
||||||
|
5,
|
||||||
|
rounds,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
scaling_points_mt.push(ScalingPoint {
|
||||||
|
n,
|
||||||
|
convergence_round: metrics.convergence_round,
|
||||||
|
total_pushes: metrics.total_pushes,
|
||||||
|
label: format!("MT N={n}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread-mode comparison ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
println!("Thread-mode comparison...");
|
||||||
|
for &threads in &[1, 2, 4] {
|
||||||
|
let config = base_config_with(
|
||||||
|
&format!("FullMesh 200 {threads}T"),
|
||||||
|
Topology::FullMesh,
|
||||||
|
200,
|
||||||
|
5,
|
||||||
|
30,
|
||||||
|
threads,
|
||||||
|
);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let metrics = analyze(&trace);
|
||||||
|
thread_comparisons.push(ThreadComparison {
|
||||||
|
label: "FullMesh 200 nodes".into(),
|
||||||
|
num_threads: threads,
|
||||||
|
convergence_round: metrics.convergence_round,
|
||||||
|
total_pushes: metrics.total_pushes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate report ─────────────────────────────────────────────────
|
||||||
|
let report_data = PropertyReportData {
|
||||||
|
sections,
|
||||||
|
scaling_points_st,
|
||||||
|
scaling_points_mt,
|
||||||
|
thread_comparison: thread_comparisons,
|
||||||
|
convergence_overlays,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = generate_property_report(&report_data);
|
||||||
|
let path = "gossip_properties_report.html";
|
||||||
|
fs::write(path, &html).expect("failed to write report");
|
||||||
|
println!("\nWrote {} ({} bytes)", path, html.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_config_with(
|
||||||
|
name: &str,
|
||||||
|
topology: Topology,
|
||||||
|
num_nodes: usize,
|
||||||
|
num_keys: usize,
|
||||||
|
num_rounds: usize,
|
||||||
|
num_threads: usize,
|
||||||
|
) -> SimConfig {
|
||||||
|
let mut config = base_config(name, topology, num_nodes, num_keys);
|
||||||
|
config.num_rounds = num_rounds;
|
||||||
|
config.num_threads = num_threads;
|
||||||
|
config
|
||||||
|
}
|
||||||
78
crates/swactor-gossip/examples/gossip_sim.rs
Normal file
78
crates/swactor-gossip/examples/gossip_sim.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use swactor_gossip::report::generate_html_report;
|
||||||
|
use swactor_gossip::sim::{run_simulation, SimConfig, Topology};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let scenarios = vec![
|
||||||
|
SimConfig {
|
||||||
|
name: "Ring (5 nodes)".into(),
|
||||||
|
topology: Topology::Ring,
|
||||||
|
num_nodes: 5,
|
||||||
|
initial_data: test_data(3),
|
||||||
|
num_rounds: 15,
|
||||||
|
ticks_per_round: 4,
|
||||||
|
heal_after_round: None,
|
||||||
|
num_threads: 1,
|
||||||
|
},
|
||||||
|
SimConfig {
|
||||||
|
name: "Star (7 nodes)".into(),
|
||||||
|
topology: Topology::Star,
|
||||||
|
num_nodes: 7,
|
||||||
|
initial_data: test_data(3),
|
||||||
|
num_rounds: 10,
|
||||||
|
ticks_per_round: 4,
|
||||||
|
heal_after_round: None,
|
||||||
|
num_threads: 1,
|
||||||
|
},
|
||||||
|
SimConfig {
|
||||||
|
name: "Full Mesh (5 nodes)".into(),
|
||||||
|
topology: Topology::FullMesh,
|
||||||
|
num_nodes: 5,
|
||||||
|
initial_data: test_data(3),
|
||||||
|
num_rounds: 8,
|
||||||
|
ticks_per_round: 4,
|
||||||
|
heal_after_round: None,
|
||||||
|
num_threads: 1,
|
||||||
|
},
|
||||||
|
SimConfig {
|
||||||
|
name: "Chain (8 nodes)".into(),
|
||||||
|
topology: Topology::Chain,
|
||||||
|
num_nodes: 8,
|
||||||
|
initial_data: test_data(3),
|
||||||
|
num_rounds: 20,
|
||||||
|
ticks_per_round: 4,
|
||||||
|
heal_after_round: None,
|
||||||
|
num_threads: 1,
|
||||||
|
},
|
||||||
|
SimConfig {
|
||||||
|
name: "Partition & Heal (6 nodes)".into(),
|
||||||
|
topology: Topology::Partitioned,
|
||||||
|
num_nodes: 6,
|
||||||
|
initial_data: test_data(3),
|
||||||
|
num_rounds: 20,
|
||||||
|
ticks_per_round: 4,
|
||||||
|
heal_after_round: Some(10),
|
||||||
|
num_threads: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for config in scenarios {
|
||||||
|
let filename = format!(
|
||||||
|
"gossip_report_{}.html",
|
||||||
|
config.name.to_lowercase().replace(' ', "_").replace(['(', ')'], "")
|
||||||
|
);
|
||||||
|
println!("Running scenario: {} ...", config.name);
|
||||||
|
let trace = run_simulation(config);
|
||||||
|
let html = generate_html_report(&trace);
|
||||||
|
fs::write(&filename, &html).expect("failed to write report");
|
||||||
|
println!(" -> wrote {filename} ({} bytes)", html.len());
|
||||||
|
}
|
||||||
|
println!("Done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_data(n: usize) -> Vec<(String, Vec<u8>)> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| (format!("key-{i}"), format!("value-{i}").into_bytes()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
10
crates/swactor-gossip/src/lib.rs
Normal file
10
crates/swactor-gossip/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod trace;
|
||||||
|
|
||||||
|
pub mod report;
|
||||||
|
pub mod sim;
|
||||||
|
|
||||||
|
pub mod properties;
|
||||||
|
pub mod property_report;
|
||||||
|
|
||||||
|
pub use protocol::{GossipActor, GossipMessage, GossipQueryResponse, GossipState, VersionedValue};
|
||||||
936
crates/swactor-gossip/src/properties.rs
Normal file
936
crates/swactor-gossip/src/properties.rs
Normal file
|
|
@ -0,0 +1,936 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
784
crates/swactor-gossip/src/property_report.rs
Normal file
784
crates/swactor-gossip/src/property_report.rs
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
use crate::properties::{GossipMetrics, PropertyResult};
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A named scenario with its metrics and property results.
|
||||||
|
pub struct ScenarioReport {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub metrics: GossipMetrics,
|
||||||
|
pub results: Vec<PropertyResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section groups related scenarios under a category heading.
|
||||||
|
pub struct ReportSection {
|
||||||
|
pub title: String,
|
||||||
|
pub explanation: String,
|
||||||
|
pub scenarios: Vec<ScenarioReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data for the scalability scatter plot.
|
||||||
|
pub struct ScalingPoint {
|
||||||
|
pub n: usize,
|
||||||
|
pub convergence_round: Option<usize>,
|
||||||
|
pub total_pushes: usize,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full report data.
|
||||||
|
pub struct PropertyReportData {
|
||||||
|
pub sections: Vec<ReportSection>,
|
||||||
|
pub scaling_points_st: Vec<ScalingPoint>,
|
||||||
|
pub scaling_points_mt: Vec<ScalingPoint>,
|
||||||
|
pub thread_comparison: Vec<ThreadComparison>,
|
||||||
|
/// All convergence curves keyed by scenario name, for the multi-line overlay.
|
||||||
|
pub convergence_overlays: Vec<(String, Vec<f64>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ThreadComparison {
|
||||||
|
pub label: String,
|
||||||
|
pub num_threads: usize,
|
||||||
|
pub convergence_round: Option<usize>,
|
||||||
|
pub total_pushes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a self-contained HTML report from the collected data.
|
||||||
|
pub fn generate_property_report(data: &PropertyReportData) -> String {
|
||||||
|
let mut html = String::with_capacity(128_000);
|
||||||
|
|
||||||
|
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
|
||||||
|
html.push_str("<title>Gossip Protocol Property Verification Report</title>\n");
|
||||||
|
html.push_str("<style>\n");
|
||||||
|
html.push_str(CSS);
|
||||||
|
html.push_str("</style>\n</head>\n<body>\n");
|
||||||
|
|
||||||
|
html.push_str("<h1>Gossip Protocol Property Verification Report</h1>\n");
|
||||||
|
|
||||||
|
// Executive summary.
|
||||||
|
render_executive_summary(&mut html, data);
|
||||||
|
|
||||||
|
// Per-section content.
|
||||||
|
for section in &data.sections {
|
||||||
|
render_section(&mut html, section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convergence overlay chart.
|
||||||
|
if !data.convergence_overlays.is_empty() {
|
||||||
|
render_convergence_overlay(&mut html, &data.convergence_overlays);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalability charts.
|
||||||
|
if !data.scaling_points_st.is_empty() {
|
||||||
|
render_scalability_section(&mut html, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread comparison.
|
||||||
|
if !data.thread_comparison.is_empty() {
|
||||||
|
render_thread_comparison(&mut html, &data.thread_comparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</body>\n</html>\n");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSS: &str = r#"
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
max-width: 1400px; margin: 0 auto; padding: 20px;
|
||||||
|
background: #fafafa; color: #222;
|
||||||
|
}
|
||||||
|
h1 { border-bottom: 3px solid #333; padding-bottom: 8px; }
|
||||||
|
h2 { margin-top: 40px; color: #333; border-bottom: 2px solid #ddd; padding-bottom: 4px; }
|
||||||
|
h3 { color: #555; margin-top: 24px; }
|
||||||
|
.summary-grid { display: flex; flex-wrap: wrap; gap: 16px; margin: 16px 0; }
|
||||||
|
.summary-card {
|
||||||
|
background: #fff; border: 1px solid #ddd; border-radius: 8px;
|
||||||
|
padding: 16px 24px; min-width: 160px;
|
||||||
|
}
|
||||||
|
.summary-card .label { font-size: 0.85em; color: #666; }
|
||||||
|
.summary-card .value { font-size: 1.8em; font-weight: bold; }
|
||||||
|
.badge-pass { display: inline-block; padding: 4px 12px; border-radius: 12px; background: #28a745; color: #fff; font-weight: bold; font-size: 0.9em; }
|
||||||
|
.badge-fail { display: inline-block; padding: 4px 12px; border-radius: 12px; background: #dc3545; color: #fff; font-weight: bold; font-size: 0.9em; }
|
||||||
|
.badge-partial { display: inline-block; padding: 4px 12px; border-radius: 12px; background: #ffc107; color: #333; font-weight: bold; font-size: 0.9em; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; font-size: 0.9em; }
|
||||||
|
th { background: #f0f0f0; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.pass { color: #28a745; font-weight: bold; }
|
||||||
|
.fail { color: #dc3545; font-weight: bold; }
|
||||||
|
svg { display: block; margin: 12px 0; }
|
||||||
|
.explanation { color: #555; margin: 8px 0 16px 0; line-height: 1.5; }
|
||||||
|
.scenario-desc { color: #777; font-style: italic; margin: 4px 0 8px 0; }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// ── Executive summary ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_executive_summary(html: &mut String, data: &PropertyReportData) {
|
||||||
|
html.push_str("<h2>Executive Summary</h2>\n");
|
||||||
|
|
||||||
|
let mut total_pass = 0usize;
|
||||||
|
let mut total_fail = 0usize;
|
||||||
|
for section in &data.sections {
|
||||||
|
for scenario in §ion.scenarios {
|
||||||
|
for r in &scenario.results {
|
||||||
|
if r.passed {
|
||||||
|
total_pass += 1;
|
||||||
|
} else {
|
||||||
|
total_fail += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = total_pass + total_fail;
|
||||||
|
|
||||||
|
let badge = if total_fail == 0 {
|
||||||
|
"<span class=\"badge-pass\">ALL PASSED</span>"
|
||||||
|
} else if total_pass == 0 {
|
||||||
|
"<span class=\"badge-fail\">ALL FAILED</span>"
|
||||||
|
} else {
|
||||||
|
"<span class=\"badge-partial\">PARTIAL</span>"
|
||||||
|
};
|
||||||
|
|
||||||
|
html.push_str("<div class=\"summary-grid\">\n");
|
||||||
|
summary_card(html, "Total Checks", &total.to_string());
|
||||||
|
summary_card(html, "Passed", &total_pass.to_string());
|
||||||
|
summary_card(html, "Failed", &total_fail.to_string());
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<div class=\"summary-card\"><div class=\"label\">Verdict</div><div class=\"value\">{badge}</div></div>\n"
|
||||||
|
));
|
||||||
|
html.push_str("</div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary_card(html: &mut String, label: &str, value: &str) {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<div class=\"summary-card\"><div class=\"label\">{label}</div><div class=\"value\">{value}</div></div>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_section(html: &mut String, section: &ReportSection) {
|
||||||
|
html.push_str(&format!("<h2>{}</h2>\n", esc(§ion.title)));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<p class=\"explanation\">{}</p>\n",
|
||||||
|
esc(§ion.explanation)
|
||||||
|
));
|
||||||
|
|
||||||
|
for scenario in §ion.scenarios {
|
||||||
|
html.push_str(&format!("<h3>{}</h3>\n", esc(&scenario.name)));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<p class=\"scenario-desc\">{}</p>\n",
|
||||||
|
esc(&scenario.description)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Results table.
|
||||||
|
html.push_str("<table>\n<tr><th>Property</th><th>Status</th><th>Expected</th><th>Actual</th><th>Description</th></tr>\n");
|
||||||
|
for r in &scenario.results {
|
||||||
|
let status = if r.passed {
|
||||||
|
"<span class=\"pass\">PASS</span>"
|
||||||
|
} else {
|
||||||
|
"<span class=\"fail\">FAIL</span>"
|
||||||
|
};
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td>{status}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
|
||||||
|
esc(&r.name),
|
||||||
|
esc(&r.expected),
|
||||||
|
esc(&r.actual),
|
||||||
|
esc(&r.description),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</table>\n");
|
||||||
|
|
||||||
|
// Inline SVG graph for this scenario based on category.
|
||||||
|
render_scenario_graph(html, section, scenario);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-scenario graphs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_scenario_graph(html: &mut String, section: &ReportSection, scenario: &ScenarioReport) {
|
||||||
|
let m = &scenario.metrics;
|
||||||
|
match section.title.as_str() {
|
||||||
|
"Convergence" | "Fault Tolerance" => {
|
||||||
|
render_convergence_curve_svg(html, &scenario.name, &m.convergence_curve);
|
||||||
|
}
|
||||||
|
"Consistency" => {
|
||||||
|
render_entropy_chart(html, &m.entropy_per_round);
|
||||||
|
}
|
||||||
|
"Practical" => {
|
||||||
|
render_state_size_chart(html, &m.avg_state_size_per_round);
|
||||||
|
}
|
||||||
|
"Bandwidth/Load" => {
|
||||||
|
render_load_bar_chart(html, m);
|
||||||
|
}
|
||||||
|
"Message Complexity" => {
|
||||||
|
render_message_stacked_bar(html, m);
|
||||||
|
}
|
||||||
|
"Peer Selection" => {
|
||||||
|
render_peer_histogram(html, m);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG chart helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHART_W: f64 = 700.0;
|
||||||
|
const CHART_H: f64 = 280.0;
|
||||||
|
const ML: f64 = 60.0; // margin left
|
||||||
|
const MR: f64 = 20.0;
|
||||||
|
const MT: f64 = 20.0;
|
||||||
|
const MB: f64 = 50.0;
|
||||||
|
|
||||||
|
fn svg_open(html: &mut String, w: f64, h: f64) {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<svg width=\"{w:.0}\" height=\"{h:.0}\" viewBox=\"0 0 {w:.0} {h:.0}\">\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svg_close(html: &mut String) {
|
||||||
|
html.push_str("</svg>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_axes(html: &mut String) {
|
||||||
|
let bx = ML;
|
||||||
|
let by = MT + CHART_H;
|
||||||
|
let rx = ML + CHART_W - ML;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{bx}\" y1=\"{MT}\" x2=\"{bx}\" y2=\"{by}\" stroke=\"#333\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{bx}\" y1=\"{by}\" x2=\"{rx}\" y2=\"{by}\" stroke=\"#333\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn y_for(val: f64, max_val: f64) -> f64 {
|
||||||
|
if max_val < 1e-9 {
|
||||||
|
return MT + CHART_H;
|
||||||
|
}
|
||||||
|
MT + CHART_H - (val / max_val) * CHART_H
|
||||||
|
}
|
||||||
|
|
||||||
|
fn x_for(idx: usize, total: usize) -> f64 {
|
||||||
|
if total == 0 {
|
||||||
|
return ML;
|
||||||
|
}
|
||||||
|
ML + (idx as f64 + 0.5) / total as f64 * (CHART_W - ML - MR)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convergence curve SVG ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_convergence_curve_svg(html: &mut String, _name: &str, curve: &[f64]) {
|
||||||
|
if curve.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
let total_h = CHART_H + MT + MB;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
draw_axes(html);
|
||||||
|
|
||||||
|
// Y-axis labels (0% to 100%).
|
||||||
|
for pct in [0, 25, 50, 75, 100] {
|
||||||
|
let y = y_for(pct as f64 / 100.0, 1.0);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{:.1}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{pct}%</text>\n",
|
||||||
|
ML - 6.0, y + 3.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{ML}\" y1=\"{y:.1}\" x2=\"{}\" y2=\"{y:.1}\" stroke=\"#eee\" stroke-width=\"1\"/>\n",
|
||||||
|
ML + CHART_W - ML - MR,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis labels.
|
||||||
|
let step = (curve.len() / 10).max(1);
|
||||||
|
for r in (0..curve.len()).step_by(step) {
|
||||||
|
let x = x_for(r, curve.len());
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x:.1}\" y=\"{}\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">{}</text>\n",
|
||||||
|
MT + CHART_H + 16.0, r + 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line.
|
||||||
|
let mut path = String::new();
|
||||||
|
for (i, &v) in curve.iter().enumerate() {
|
||||||
|
let x = x_for(i, curve.len());
|
||||||
|
let y = y_for(v, 1.0);
|
||||||
|
if i == 0 {
|
||||||
|
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{path}\" fill=\"none\" stroke=\"#4a90d9\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Dots.
|
||||||
|
let dot_step = (curve.len() / 30).max(1);
|
||||||
|
for (i, &v) in curve.iter().enumerate() {
|
||||||
|
if i % dot_step == 0 {
|
||||||
|
let x = x_for(i, curve.len());
|
||||||
|
let y = y_for(v, 1.0);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"2.5\" fill=\"#4a90d9\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-line convergence overlay ──────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_convergence_overlay(html: &mut String, curves: &[(String, Vec<f64>)]) {
|
||||||
|
html.push_str("<h2>Convergence Comparison (All Topologies)</h2>\n");
|
||||||
|
html.push_str("<p class=\"explanation\">Overlay of convergence curves across different topologies at scale.</p>\n");
|
||||||
|
|
||||||
|
let max_len = curves.iter().map(|(_, c)| c.len()).max().unwrap_or(0);
|
||||||
|
if max_len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
let total_h = CHART_H + MT + MB + 40.0; // extra for legend
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
draw_axes(html);
|
||||||
|
|
||||||
|
let colors = ["#4a90d9", "#d94a4a", "#4ad94a", "#d9a64a", "#9a4ad9", "#4ad9d9"];
|
||||||
|
|
||||||
|
for pct in [0, 25, 50, 75, 100] {
|
||||||
|
let y = y_for(pct as f64 / 100.0, 1.0);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{:.1}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{pct}%</text>\n",
|
||||||
|
ML - 6.0, y + 3.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ci, (name, curve)) in curves.iter().enumerate() {
|
||||||
|
let color = colors[ci % colors.len()];
|
||||||
|
let mut path = String::new();
|
||||||
|
for (i, &v) in curve.iter().enumerate() {
|
||||||
|
let x = x_for(i, max_len);
|
||||||
|
let y = y_for(v, 1.0);
|
||||||
|
if i == 0 {
|
||||||
|
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{path}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Legend entry.
|
||||||
|
let lx = ML + ci as f64 * 140.0;
|
||||||
|
let ly = MT + CHART_H + 36.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{lx:.0}\" y=\"{ly:.0}\" width=\"14\" height=\"10\" fill=\"{color}\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">{name}</text>\n",
|
||||||
|
lx + 18.0, ly + 9.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entropy chart ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_entropy_chart(html: &mut String, entropy: &[usize]) {
|
||||||
|
if entropy.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_e = *entropy.iter().max().unwrap_or(&1) as f64;
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
let total_h = CHART_H + MT + MB;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
draw_axes(html);
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{}</text>\n",
|
||||||
|
ML - 6.0, MT + 3.0, max_e as usize,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">0</text>\n",
|
||||||
|
ML - 6.0, MT + CHART_H + 3.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut path = String::new();
|
||||||
|
for (i, &e) in entropy.iter().enumerate() {
|
||||||
|
let x = x_for(i, entropy.len());
|
||||||
|
let y = y_for(e as f64, max_e);
|
||||||
|
if i == 0 {
|
||||||
|
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{path}\" fill=\"none\" stroke=\"#d94a4a\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State size chart ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_state_size_chart(html: &mut String, sizes: &[f64]) {
|
||||||
|
if sizes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_s = sizes.iter().cloned().fold(0.0f64, f64::max).max(1.0);
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
let total_h = CHART_H + MT + MB;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
draw_axes(html);
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{:.1}</text>\n",
|
||||||
|
ML - 6.0, MT + 3.0, max_s,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut path = String::new();
|
||||||
|
for (i, &s) in sizes.iter().enumerate() {
|
||||||
|
let x = x_for(i, sizes.len());
|
||||||
|
let y = y_for(s, max_s);
|
||||||
|
if i == 0 {
|
||||||
|
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{path}\" fill=\"none\" stroke=\"#4a90d9\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load bar chart ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_load_bar_chart(html: &mut String, metrics: &GossipMetrics) {
|
||||||
|
let mut nodes: Vec<(&String, usize)> = metrics
|
||||||
|
.pushes_received_per_node
|
||||||
|
.iter()
|
||||||
|
.map(|(n, &c)| (n, c))
|
||||||
|
.collect();
|
||||||
|
nodes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
// Show top 20 nodes.
|
||||||
|
nodes.truncate(20);
|
||||||
|
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_v = nodes[0].1 as f64;
|
||||||
|
let bar_h = 18.0;
|
||||||
|
let gap = 4.0;
|
||||||
|
let total_h = MT + (bar_h + gap) * nodes.len() as f64 + MB;
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
|
||||||
|
for (i, (name, count)) in nodes.iter().enumerate() {
|
||||||
|
let y = MT + i as f64 * (bar_h + gap);
|
||||||
|
let w = if max_v > 0.0 {
|
||||||
|
(*count as f64 / max_v) * (CHART_W - ML - MR - 40.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"end\" font-size=\"10\" fill=\"#333\">{name}</text>\n",
|
||||||
|
ML - 4.0, y + bar_h - 4.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{ML}\" y=\"{y:.1}\" width=\"{w:.1}\" height=\"{bar_h}\" fill=\"#4a90d9\" rx=\"3\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">{count}</text>\n",
|
||||||
|
ML + w + 4.0, y + bar_h - 4.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message stacked bar ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_message_stacked_bar(html: &mut String, metrics: &GossipMetrics) {
|
||||||
|
let useful = metrics.total_pushes - metrics.redundant_pushes;
|
||||||
|
let redundant = metrics.redundant_pushes;
|
||||||
|
let total = metrics.total_pushes.max(1) as f64;
|
||||||
|
|
||||||
|
let total_w = 400.0;
|
||||||
|
let total_h = 80.0;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
|
||||||
|
let bar_w = 300.0;
|
||||||
|
let bar_h = 30.0;
|
||||||
|
let y = 20.0;
|
||||||
|
let x = 60.0;
|
||||||
|
|
||||||
|
let useful_w = (useful as f64 / total) * bar_w;
|
||||||
|
let redundant_w = (redundant as f64 / total) * bar_w;
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{x}\" y=\"{y}\" width=\"{useful_w:.1}\" height=\"{bar_h}\" fill=\"#28a745\" rx=\"3\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{}\" y=\"{y}\" width=\"{redundant_w:.1}\" height=\"{bar_h}\" fill=\"#dc3545\" rx=\"3\"/>\n",
|
||||||
|
x + useful_w,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Legend.
|
||||||
|
let ly = y + bar_h + 16.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{x}\" y=\"{ly}\" width=\"12\" height=\"10\" fill=\"#28a745\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">Useful ({useful})</text>\n",
|
||||||
|
x + 16.0, ly + 9.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{}\" y=\"{ly}\" width=\"12\" height=\"10\" fill=\"#dc3545\"/>\n",
|
||||||
|
x + 140.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">Redundant ({redundant})</text>\n",
|
||||||
|
x + 156.0, ly + 9.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer selection histogram ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_peer_histogram(html: &mut String, metrics: &GossipMetrics) {
|
||||||
|
// Aggregate: for each target, total selection count across all nodes.
|
||||||
|
let mut target_totals: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||||
|
for targets in metrics.peer_selection_distribution.values() {
|
||||||
|
for (target, &count) in targets {
|
||||||
|
*target_totals.entry(target.clone()).or_default() += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut sorted: Vec<(String, usize)> = target_totals.into_iter().collect();
|
||||||
|
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
if sorted.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_v = sorted.iter().map(|(_, c)| *c).max().unwrap_or(1) as f64;
|
||||||
|
let bar_w = 30.0;
|
||||||
|
let gap = 4.0;
|
||||||
|
let total_w = ML + (bar_w + gap) * sorted.len() as f64 + MR;
|
||||||
|
let total_h = CHART_H + MT + MB;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
|
||||||
|
// Axes.
|
||||||
|
let base_y = MT + CHART_H;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{ML}\" y1=\"{MT}\" x2=\"{ML}\" y2=\"{base_y}\" stroke=\"#333\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{ML}\" y1=\"{base_y}\" x2=\"{}\" y2=\"{base_y}\" stroke=\"#333\" stroke-width=\"1\"/>\n",
|
||||||
|
total_w - MR,
|
||||||
|
));
|
||||||
|
|
||||||
|
for (i, (name, count)) in sorted.iter().enumerate() {
|
||||||
|
let x = ML + i as f64 * (bar_w + gap);
|
||||||
|
let h = (*count as f64 / max_v) * CHART_H;
|
||||||
|
let y = base_y - h;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{bar_w}\" height=\"{h:.1}\" fill=\"#4a90d9\" rx=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
// Label.
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#666\" transform=\"rotate(-45 {} {})\">{name}</text>\n",
|
||||||
|
x + bar_w / 2.0, base_y + 14.0, x + bar_w / 2.0, base_y + 14.0,
|
||||||
|
));
|
||||||
|
// Count on top.
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#333\">{count}</text>\n",
|
||||||
|
x + bar_w / 2.0, y - 3.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scalability section ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_scalability_section(html: &mut String, data: &PropertyReportData) {
|
||||||
|
html.push_str("<h2>Scalability</h2>\n");
|
||||||
|
html.push_str("<p class=\"explanation\">How convergence time and message count scale with network size.</p>\n");
|
||||||
|
|
||||||
|
// Convergence round vs N (with O(log N) reference).
|
||||||
|
html.push_str("<h3>Convergence Time vs Network Size</h3>\n");
|
||||||
|
render_scaling_scatter(
|
||||||
|
html,
|
||||||
|
&data.scaling_points_st,
|
||||||
|
&data.scaling_points_mt,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total messages vs N (with O(N) reference).
|
||||||
|
html.push_str("<h3>Total Messages vs Network Size</h3>\n");
|
||||||
|
render_scaling_scatter(
|
||||||
|
html,
|
||||||
|
&data.scaling_points_st,
|
||||||
|
&data.scaling_points_mt,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_scaling_scatter(
|
||||||
|
html: &mut String,
|
||||||
|
st_points: &[ScalingPoint],
|
||||||
|
mt_points: &[ScalingPoint],
|
||||||
|
is_convergence: bool,
|
||||||
|
) {
|
||||||
|
let all_n: Vec<usize> = st_points
|
||||||
|
.iter()
|
||||||
|
.chain(mt_points.iter())
|
||||||
|
.map(|p| p.n)
|
||||||
|
.collect();
|
||||||
|
let all_y: Vec<f64> = st_points
|
||||||
|
.iter()
|
||||||
|
.chain(mt_points.iter())
|
||||||
|
.map(|p| {
|
||||||
|
if is_convergence {
|
||||||
|
p.convergence_round.unwrap_or(0) as f64
|
||||||
|
} else {
|
||||||
|
p.total_pushes as f64
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if all_n.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_n = *all_n.iter().max().unwrap() as f64;
|
||||||
|
let max_y = all_y.iter().cloned().fold(0.0f64, f64::max).max(1.0);
|
||||||
|
|
||||||
|
let total_w = CHART_W + MR;
|
||||||
|
let total_h = CHART_H + MT + MB + 30.0;
|
||||||
|
svg_open(html, total_w, total_h);
|
||||||
|
draw_axes(html);
|
||||||
|
|
||||||
|
// Reference line.
|
||||||
|
let ref_color = "#ccc";
|
||||||
|
let ref_points = 50;
|
||||||
|
let mut ref_path = String::new();
|
||||||
|
for i in 0..=ref_points {
|
||||||
|
let n = (i as f64 / ref_points as f64) * max_n;
|
||||||
|
let ref_y_val = if is_convergence {
|
||||||
|
// O(log N) reference scaled to fit.
|
||||||
|
if n > 1.0 {
|
||||||
|
(n.ln() / max_n.ln()) * max_y
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// O(N) reference.
|
||||||
|
(n / max_n) * max_y
|
||||||
|
};
|
||||||
|
let x = ML + (n / max_n) * (CHART_W - ML - MR);
|
||||||
|
let y = y_for(ref_y_val, max_y);
|
||||||
|
if i == 0 {
|
||||||
|
ref_path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
ref_path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{ref_path}\" fill=\"none\" stroke=\"{ref_color}\" stroke-width=\"1.5\" stroke-dasharray=\"6,4\"/>\n"
|
||||||
|
));
|
||||||
|
let ref_label = if is_convergence { "O(log N)" } else { "O(N)" };
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#aaa\">{ref_label}</text>\n",
|
||||||
|
ML + CHART_W - ML - MR - 50.0, MT + 14.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Single-threaded points.
|
||||||
|
for p in st_points {
|
||||||
|
let x = ML + (p.n as f64 / max_n) * (CHART_W - ML - MR);
|
||||||
|
let yv = if is_convergence {
|
||||||
|
p.convergence_round.unwrap_or(0) as f64
|
||||||
|
} else {
|
||||||
|
p.total_pushes as f64
|
||||||
|
};
|
||||||
|
let y = y_for(yv, max_y);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"5\" fill=\"#4a90d9\" stroke=\"#2a5a9d\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-threaded points.
|
||||||
|
for p in mt_points {
|
||||||
|
let x = ML + (p.n as f64 / max_n) * (CHART_W - ML - MR);
|
||||||
|
let yv = if is_convergence {
|
||||||
|
p.convergence_round.unwrap_or(0) as f64
|
||||||
|
} else {
|
||||||
|
p.total_pushes as f64
|
||||||
|
};
|
||||||
|
let y = y_for(yv, max_y);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"5\" fill=\"#d94a4a\" stroke=\"#9d2a2a\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend.
|
||||||
|
let ly = MT + CHART_H + 30.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{ML}\" cy=\"{ly}\" r=\"5\" fill=\"#4a90d9\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">Single-threaded</text>\n",
|
||||||
|
ML + 10.0, ly + 4.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{}\" cy=\"{ly}\" r=\"5\" fill=\"#d94a4a\"/>\n",
|
||||||
|
ML + 130.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"#333\">Multi-threaded</text>\n",
|
||||||
|
ML + 140.0, ly + 4.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
svg_close(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread comparison ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_thread_comparison(html: &mut String, comparisons: &[ThreadComparison]) {
|
||||||
|
html.push_str("<h2>Thread-Mode Comparison</h2>\n");
|
||||||
|
html.push_str("<p class=\"explanation\">Comparing convergence time and message counts across different thread configurations.</p>\n");
|
||||||
|
|
||||||
|
html.push_str("<table>\n<tr><th>Configuration</th><th>Threads</th><th>Convergence Round</th><th>Total Pushes</th></tr>\n");
|
||||||
|
for tc in comparisons {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
|
||||||
|
esc(&tc.label),
|
||||||
|
tc.num_threads,
|
||||||
|
tc.convergence_round
|
||||||
|
.map(|r| r.to_string())
|
||||||
|
.unwrap_or("never".into()),
|
||||||
|
tc.total_pushes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</table>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML escape ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
341
crates/swactor-gossip/src/protocol.rs
Normal file
341
crates/swactor-gossip/src/protocol.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use log::{debug, trace};
|
||||||
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
|
||||||
|
use crate::trace::{GossipEvent, GossipEventKind, NodeSnapshot, TraceContext};
|
||||||
|
|
||||||
|
// ── VersionedValue ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct VersionedValue {
|
||||||
|
pub value: Vec<u8>,
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GossipState ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct GossipState {
|
||||||
|
entries: HashMap<String, VersionedValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GossipState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or update a key. Auto-increments the version for that key.
|
||||||
|
/// Returns the new version number.
|
||||||
|
pub fn set(&mut self, key: String, value: Vec<u8>) -> u64 {
|
||||||
|
let new_version = self
|
||||||
|
.entries
|
||||||
|
.get(&key)
|
||||||
|
.map_or(1, |existing| existing.version + 1);
|
||||||
|
self.entries.insert(
|
||||||
|
key,
|
||||||
|
VersionedValue {
|
||||||
|
value,
|
||||||
|
version: new_version,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
new_version
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Option<&VersionedValue> {
|
||||||
|
self.entries.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> &HashMap<String, VersionedValue> {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a remote state into this one. For each key, keep the entry
|
||||||
|
/// with the higher version (last-writer-wins). Returns the number of
|
||||||
|
/// entries that were updated.
|
||||||
|
pub fn merge(&mut self, remote: &GossipState) -> usize {
|
||||||
|
let mut updated = 0;
|
||||||
|
for (key, remote_val) in &remote.entries {
|
||||||
|
let dominated = match self.entries.get(key) {
|
||||||
|
Some(local_val) => remote_val.version > local_val.version,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if dominated {
|
||||||
|
self.entries.insert(key.clone(), remote_val.clone());
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GossipQueryResponse ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GossipQueryResponse {
|
||||||
|
pub key: String,
|
||||||
|
pub value: Option<Vec<u8>>,
|
||||||
|
pub version: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GossipMessage ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum GossipMessage {
|
||||||
|
/// Register a peer to gossip with.
|
||||||
|
AddPeer(ActorAddress),
|
||||||
|
/// Remove a peer from the gossip set.
|
||||||
|
RemovePeer(ActorAddress),
|
||||||
|
/// Set a key-value pair in this node's local state.
|
||||||
|
Set { key: String, value: Vec<u8> },
|
||||||
|
/// Trigger a gossip round: pick a random peer and push our full state.
|
||||||
|
DoGossipRound,
|
||||||
|
/// Incoming state push from a peer.
|
||||||
|
Push {
|
||||||
|
from: ActorAddress,
|
||||||
|
state: GossipState,
|
||||||
|
},
|
||||||
|
/// Query the current value for a key; response sent to `reply_to`.
|
||||||
|
Query {
|
||||||
|
key: String,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
/// Ask the actor to dump its current state into the event log (tracing only).
|
||||||
|
TakeSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GossipActor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct GossipActor {
|
||||||
|
state: GossipState,
|
||||||
|
peers: Vec<ActorAddress>,
|
||||||
|
trace: Option<TraceContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GossipActor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: GossipState::new(),
|
||||||
|
peers: Vec::new(),
|
||||||
|
trace: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a traced actor that records events into the shared log.
|
||||||
|
pub fn traced(
|
||||||
|
log: crate::trace::EventLog,
|
||||||
|
tick: crate::trace::TickCounter,
|
||||||
|
names: crate::trace::NameRegistry,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state: GossipState::new(),
|
||||||
|
peers: Vec::new(),
|
||||||
|
trace: Some(TraceContext {
|
||||||
|
event_log: log,
|
||||||
|
tick_counter: tick,
|
||||||
|
name_registry: names,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_random_peer(&self) -> Option<ActorAddress> {
|
||||||
|
if self.peers.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut buf = [0u8; 8];
|
||||||
|
getrandom::getrandom(&mut buf).unwrap();
|
||||||
|
let idx = usize::from_ne_bytes(buf) % self.peers.len();
|
||||||
|
Some(self.peers[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, addr: ActorAddress, kind: GossipEventKind) {
|
||||||
|
if let Some(trace) = &self.trace {
|
||||||
|
let event = GossipEvent {
|
||||||
|
tick: trace.current_tick(),
|
||||||
|
node_name: trace.resolve_name(addr),
|
||||||
|
node_addr: addr,
|
||||||
|
thread_name: std::thread::current().name().map(|s| s.to_owned()),
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
trace.record_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_name(&self, addr: ActorAddress) -> String {
|
||||||
|
self.trace
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.resolve_name(addr))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", &addr.0[..4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_name(&self, addr: ActorAddress) -> String {
|
||||||
|
self.self_name(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round(&self) -> u64 {
|
||||||
|
self.trace
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.current_tick())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GossipActor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for GossipActor {
|
||||||
|
type Incoming = GossipMessage;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: GossipMessage) {
|
||||||
|
let self_addr = ctx.self_addr();
|
||||||
|
match msg {
|
||||||
|
GossipMessage::AddPeer(addr) => {
|
||||||
|
if !self.peers.contains(&addr) {
|
||||||
|
let pname = self.peer_name(addr);
|
||||||
|
debug!(
|
||||||
|
"[{}] inbound AddPeer peer={} (total_peers={})",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
pname,
|
||||||
|
self.peers.len() + 1,
|
||||||
|
);
|
||||||
|
self.peers.push(addr);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::PeerAdded {
|
||||||
|
peer_name: pname,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GossipMessage::RemovePeer(addr) => {
|
||||||
|
let before = self.peers.len();
|
||||||
|
self.peers.retain(|a| *a != addr);
|
||||||
|
if self.peers.len() < before {
|
||||||
|
let pname = self.peer_name(addr);
|
||||||
|
debug!(
|
||||||
|
"[{}] inbound RemovePeer peer={} (total_peers={})",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
pname,
|
||||||
|
self.peers.len(),
|
||||||
|
);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::PeerRemoved {
|
||||||
|
peer_name: pname,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GossipMessage::Set { key, value } => {
|
||||||
|
debug!(
|
||||||
|
"[{}] inbound Set key={:?} value_len={}",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
key,
|
||||||
|
value.len(),
|
||||||
|
);
|
||||||
|
self.state.set(key.clone(), value);
|
||||||
|
self.record(self_addr, GossipEventKind::LocalSet { key });
|
||||||
|
}
|
||||||
|
GossipMessage::DoGossipRound => {
|
||||||
|
let round = self.round();
|
||||||
|
if let Some(peer) = self.pick_random_peer() {
|
||||||
|
let target = self.peer_name(peer);
|
||||||
|
debug!(
|
||||||
|
"[{}] round={} outbound Push -> {} (state_keys={})",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
round,
|
||||||
|
target,
|
||||||
|
self.state.entries().len(),
|
||||||
|
);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::GossipRoundStarted {
|
||||||
|
target_name: target,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = ctx.send(
|
||||||
|
peer,
|
||||||
|
GossipMessage::Push {
|
||||||
|
from: self_addr,
|
||||||
|
state: self.state.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"[{}] round={} no peers — skipping gossip",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
round,
|
||||||
|
);
|
||||||
|
self.record(self_addr, GossipEventKind::GossipRoundNoPeers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GossipMessage::Push {
|
||||||
|
from,
|
||||||
|
state: remote,
|
||||||
|
} => {
|
||||||
|
let from_name = self.peer_name(from);
|
||||||
|
let keys_updated = self.state.merge(&remote);
|
||||||
|
debug!(
|
||||||
|
"[{}] round={} inbound Push <- {} keys_updated={} (state_keys={})",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
self.round(),
|
||||||
|
from_name,
|
||||||
|
keys_updated,
|
||||||
|
self.state.entries().len(),
|
||||||
|
);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::PushReceived {
|
||||||
|
from_name,
|
||||||
|
keys_updated,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GossipMessage::TakeSnapshot => {
|
||||||
|
trace!(
|
||||||
|
"[{}] round={} snapshot (keys={})",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
self.round(),
|
||||||
|
self.state.entries().len(),
|
||||||
|
);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::StateSnapshot {
|
||||||
|
snapshot: NodeSnapshot {
|
||||||
|
entries: self.state.entries().clone(),
|
||||||
|
peer_count: self.peers.len(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GossipMessage::Query { key, reply_to } => {
|
||||||
|
let entry = self.state.get(&key);
|
||||||
|
debug!(
|
||||||
|
"[{}] inbound Query key={:?} found={}",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
key,
|
||||||
|
entry.is_some(),
|
||||||
|
);
|
||||||
|
self.record(
|
||||||
|
self_addr,
|
||||||
|
GossipEventKind::QueryReceived { key: key.clone() },
|
||||||
|
);
|
||||||
|
let resp = GossipQueryResponse {
|
||||||
|
key,
|
||||||
|
value: entry.map(|e| e.value.clone()),
|
||||||
|
version: entry.map(|e| e.version),
|
||||||
|
};
|
||||||
|
debug!(
|
||||||
|
"[{}] outbound QueryResponse -> {:?}",
|
||||||
|
self.self_name(self_addr),
|
||||||
|
&reply_to.0[..4],
|
||||||
|
);
|
||||||
|
let _ = ctx.send(reply_to, resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
550
crates/swactor-gossip/src/report.rs
Normal file
550
crates/swactor-gossip/src/report.rs
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
use crate::trace::{GossipEventKind, SimulationTrace};
|
||||||
|
|
||||||
|
/// Generate a self-contained HTML report from a simulation trace.
|
||||||
|
pub fn generate_html_report(trace: &SimulationTrace) -> String {
|
||||||
|
let mut html = String::with_capacity(32_000);
|
||||||
|
|
||||||
|
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<title>Gossip Simulation: {}</title>\n",
|
||||||
|
escape_html(&trace.name)
|
||||||
|
));
|
||||||
|
html.push_str("<style>\n");
|
||||||
|
html.push_str(CSS);
|
||||||
|
html.push_str("</style>\n</head>\n<body>\n");
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<h1>Gossip Simulation: {}</h1>\n",
|
||||||
|
escape_html(&trace.name)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Summary metrics
|
||||||
|
render_summary(&mut html, trace);
|
||||||
|
|
||||||
|
// Network topology
|
||||||
|
render_topology_svg(&mut html, trace);
|
||||||
|
|
||||||
|
// Propagation heatmap
|
||||||
|
render_heatmap_svg(&mut html, trace);
|
||||||
|
|
||||||
|
// Convergence curve
|
||||||
|
render_convergence_svg(&mut html, trace);
|
||||||
|
|
||||||
|
// Message flow timeline
|
||||||
|
render_message_flow_svg(&mut html, trace);
|
||||||
|
|
||||||
|
// Event log table
|
||||||
|
render_event_table(&mut html, trace);
|
||||||
|
|
||||||
|
html.push_str("</body>\n</html>\n");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSS: &str = r#"
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
max-width: 1200px; margin: 0 auto; padding: 20px;
|
||||||
|
background: #fafafa; color: #222;
|
||||||
|
}
|
||||||
|
h1 { border-bottom: 3px solid #333; padding-bottom: 8px; }
|
||||||
|
h2 { margin-top: 32px; color: #444; }
|
||||||
|
.metrics { display: flex; flex-wrap: wrap; gap: 16px; margin: 16px 0; }
|
||||||
|
.metric {
|
||||||
|
background: #fff; border: 1px solid #ddd; border-radius: 8px;
|
||||||
|
padding: 12px 20px; min-width: 140px;
|
||||||
|
}
|
||||||
|
.metric .label { font-size: 0.85em; color: #666; }
|
||||||
|
.metric .value { font-size: 1.5em; font-weight: bold; }
|
||||||
|
svg { display: block; margin: 12px 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 0.85em; }
|
||||||
|
th { background: #f0f0f0; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.capped { color: #999; font-style: italic; margin: 4px 0; }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// ── Summary metrics ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_summary(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
let num_nodes = trace.node_names.len();
|
||||||
|
let total_pushes = trace
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.kind, GossipEventKind::GossipRoundStarted { .. }))
|
||||||
|
.count();
|
||||||
|
let redundant_pushes = trace
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.kind, GossipEventKind::PushReceived { keys_updated: 0, .. }))
|
||||||
|
.count();
|
||||||
|
let convergence_round = find_convergence_round(trace);
|
||||||
|
|
||||||
|
html.push_str("<h2>Summary</h2>\n<div class=\"metrics\">\n");
|
||||||
|
metric(html, "Nodes", &num_nodes.to_string());
|
||||||
|
metric(html, "Keys", &trace.total_keys.to_string());
|
||||||
|
metric(html, "Rounds", &trace.num_rounds.to_string());
|
||||||
|
metric(html, "Pushes", &total_pushes.to_string());
|
||||||
|
metric(html, "Redundant", &redundant_pushes.to_string());
|
||||||
|
metric(
|
||||||
|
html,
|
||||||
|
"Converged at",
|
||||||
|
&convergence_round
|
||||||
|
.map(|r| format!("round {r}"))
|
||||||
|
.unwrap_or_else(|| "never".into()),
|
||||||
|
);
|
||||||
|
if total_pushes > 0 {
|
||||||
|
let efficiency = 100.0 * (1.0 - redundant_pushes as f64 / total_pushes as f64);
|
||||||
|
metric(html, "Efficiency", &format!("{efficiency:.0}%"));
|
||||||
|
}
|
||||||
|
html.push_str("</div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metric(html: &mut String, label: &str, value: &str) {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<div class=\"metric\"><div class=\"label\">{label}</div><div class=\"value\">{value}</div></div>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_convergence_round(trace: &SimulationTrace) -> Option<usize> {
|
||||||
|
if trace.total_keys == 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
for (round_idx, snapshots) in trace.snapshots_per_round.iter().enumerate() {
|
||||||
|
let all_converged = snapshots
|
||||||
|
.iter()
|
||||||
|
.all(|(_, snap)| snap.entries.len() >= trace.total_keys);
|
||||||
|
if all_converged {
|
||||||
|
return Some(round_idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topology SVG ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_topology_svg(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
html.push_str("<h2>Network Topology</h2>\n");
|
||||||
|
|
||||||
|
let n = trace.node_names.len();
|
||||||
|
let size = 400.0_f64;
|
||||||
|
let cx = size / 2.0;
|
||||||
|
let cy = size / 2.0;
|
||||||
|
let radius = size / 2.0 - 50.0;
|
||||||
|
|
||||||
|
// Compute node positions in circular layout.
|
||||||
|
let positions: Vec<(f64, f64)> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let angle = 2.0 * PI * (i as f64) / (n as f64) - PI / 2.0;
|
||||||
|
(cx + radius * angle.cos(), cy + radius * angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let name_to_idx: HashMap<&str, usize> = trace
|
||||||
|
.node_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (n.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<svg width=\"{size}\" height=\"{size}\" viewBox=\"0 0 {size} {size}\">\n"
|
||||||
|
));
|
||||||
|
html.push_str("<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6\" fill=\"#888\"/></marker></defs>\n");
|
||||||
|
|
||||||
|
// Draw edges.
|
||||||
|
for (from_name, to_name) in &trace.topology_edges {
|
||||||
|
if let (Some(&fi), Some(&ti)) = (name_to_idx.get(from_name.as_str()), name_to_idx.get(to_name.as_str())) {
|
||||||
|
let (x1, y1) = positions[fi];
|
||||||
|
let (x2, y2) = positions[ti];
|
||||||
|
// Shorten line to not overlap circle.
|
||||||
|
let dx = x2 - x1;
|
||||||
|
let dy = y2 - y1;
|
||||||
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
|
if len > 0.0 {
|
||||||
|
let nx = dx / len;
|
||||||
|
let ny = dy / len;
|
||||||
|
let sx = x1 + nx * 18.0;
|
||||||
|
let sy = y1 + ny * 18.0;
|
||||||
|
let ex = x2 - nx * 18.0;
|
||||||
|
let ey = y2 - ny * 18.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{sx:.1}\" y1=\"{sy:.1}\" x2=\"{ex:.1}\" y2=\"{ey:.1}\" stroke=\"#aaa\" stroke-width=\"1\" marker-end=\"url(#arrow)\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw nodes.
|
||||||
|
for (i, name) in trace.node_names.iter().enumerate() {
|
||||||
|
let (x, y) = positions[i];
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"16\" fill=\"#4a90d9\" stroke=\"#2a5a9d\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"10\" font-weight=\"bold\">{name}</text>\n",
|
||||||
|
ty = y + 4.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</svg>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Propagation heatmap ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_heatmap_svg(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
html.push_str("<h2>Propagation Heatmap</h2>\n");
|
||||||
|
html.push_str("<p>Rows = nodes, columns = rounds. Color intensity = fraction of total keys held.</p>\n");
|
||||||
|
|
||||||
|
let n = trace.node_names.len();
|
||||||
|
let rounds = trace.snapshots_per_round.len();
|
||||||
|
if rounds == 0 || n == 0 {
|
||||||
|
html.push_str("<p>No data.</p>\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell_w = 36.0_f64;
|
||||||
|
let cell_h = 28.0_f64;
|
||||||
|
let label_w = 80.0_f64;
|
||||||
|
let header_h = 28.0_f64;
|
||||||
|
let w = label_w + cell_w * rounds as f64 + 10.0;
|
||||||
|
let h = header_h + cell_h * n as f64 + 10.0;
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<svg width=\"{w:.0}\" height=\"{h:.0}\" viewBox=\"0 0 {w:.0} {h:.0}\">\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Column headers.
|
||||||
|
for r in 0..rounds {
|
||||||
|
let x = label_w + r as f64 * cell_w + cell_w / 2.0;
|
||||||
|
let ty = header_h - 6.0;
|
||||||
|
let label = r + 1;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x:.1}\" y=\"{ty}\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">R{label}</text>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a name→row index for stable ordering.
|
||||||
|
let name_to_row: HashMap<&str, usize> = trace
|
||||||
|
.node_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (n.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (r, round_snaps) in trace.snapshots_per_round.iter().enumerate() {
|
||||||
|
for (name, snap) in round_snaps {
|
||||||
|
if let Some(&row) = name_to_row.get(name.as_str()) {
|
||||||
|
let frac = if trace.total_keys > 0 {
|
||||||
|
snap.entries.len() as f64 / trace.total_keys as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let x = label_w + r as f64 * cell_w;
|
||||||
|
let y = header_h + row as f64 * cell_h;
|
||||||
|
let color = heatmap_color(frac);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{cell_w}\" height=\"{cell_h}\" fill=\"{color}\" stroke=\"#fff\" stroke-width=\"1\"/>\n"
|
||||||
|
));
|
||||||
|
// Show count inside cell.
|
||||||
|
let text_color = if frac > 0.5 { "#fff" } else { "#333" };
|
||||||
|
let tx = x + cell_w / 2.0;
|
||||||
|
let ty = y + cell_h / 2.0 + 3.0;
|
||||||
|
let count = snap.entries.len();
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" font-size=\"10\" fill=\"{text_color}\">{count}</text>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row labels.
|
||||||
|
for (i, name) in trace.node_names.iter().enumerate() {
|
||||||
|
let y = header_h + i as f64 * cell_h + cell_h / 2.0 + 4.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x}\" y=\"{y:.1}\" font-size=\"11\" fill=\"#333\">{name}</text>\n",
|
||||||
|
x = 4.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</svg>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heatmap_color(frac: f64) -> String {
|
||||||
|
// Interpolate from light (#e8f4e8) to deep green (#1a7a1a).
|
||||||
|
let f = frac.clamp(0.0, 1.0);
|
||||||
|
let r = (232.0 + f * (26.0 - 232.0)) as u8;
|
||||||
|
let g = (244.0 + f * (122.0 - 244.0)) as u8;
|
||||||
|
let b = (232.0 + f * (26.0 - 232.0)) as u8;
|
||||||
|
format!("#{r:02x}{g:02x}{b:02x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convergence curve ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_convergence_svg(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
html.push_str("<h2>Convergence Curve</h2>\n");
|
||||||
|
html.push_str("<p>Percentage of nodes that hold all keys vs. round number.</p>\n");
|
||||||
|
|
||||||
|
let rounds = trace.snapshots_per_round.len();
|
||||||
|
if rounds == 0 {
|
||||||
|
html.push_str("<p>No data.</p>\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chart_w = 600.0_f64;
|
||||||
|
let chart_h = 300.0_f64;
|
||||||
|
let margin_l = 50.0_f64;
|
||||||
|
let margin_b = 40.0_f64;
|
||||||
|
let margin_t = 20.0_f64;
|
||||||
|
let margin_r = 20.0_f64;
|
||||||
|
let w = chart_w + margin_l + margin_r;
|
||||||
|
let h = chart_h + margin_t + margin_b;
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<svg width=\"{w:.0}\" height=\"{h:.0}\" viewBox=\"0 0 {w:.0} {h:.0}\">\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Axes.
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{margin_l}\" y1=\"{margin_t}\" x2=\"{margin_l}\" y2=\"{}\" stroke=\"#333\" stroke-width=\"1\"/>\n",
|
||||||
|
margin_t + chart_h,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{margin_l}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#333\" stroke-width=\"1\"/>\n",
|
||||||
|
margin_t + chart_h,
|
||||||
|
margin_l + chart_w,
|
||||||
|
margin_t + chart_h,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Y-axis labels.
|
||||||
|
for pct in [0, 25, 50, 75, 100] {
|
||||||
|
let y = margin_t + chart_h - (pct as f64 / 100.0) * chart_h;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{:.1}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{pct}%</text>\n",
|
||||||
|
margin_l - 6.0, y + 3.0,
|
||||||
|
));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{margin_l}\" y1=\"{y:.1}\" x2=\"{}\" y2=\"{y:.1}\" stroke=\"#eee\" stroke-width=\"1\"/>\n",
|
||||||
|
margin_l + chart_w,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis labels.
|
||||||
|
let step = (rounds / 10).max(1);
|
||||||
|
for r in (0..rounds).step_by(step) {
|
||||||
|
let x = margin_l + (r as f64 + 0.5) / rounds as f64 * chart_w;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x:.1}\" y=\"{}\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">{}</text>\n",
|
||||||
|
margin_t + chart_h + 16.0, r + 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// X-axis title.
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"11\" fill=\"#444\">Round</text>\n",
|
||||||
|
margin_l + chart_w / 2.0,
|
||||||
|
margin_t + chart_h + 34.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Compute data points.
|
||||||
|
let n = trace.node_names.len();
|
||||||
|
let mut points = Vec::with_capacity(rounds);
|
||||||
|
for round_snaps in &trace.snapshots_per_round {
|
||||||
|
let converged = round_snaps
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, snap)| snap.entries.len() >= trace.total_keys && trace.total_keys > 0)
|
||||||
|
.count();
|
||||||
|
let pct = if n > 0 {
|
||||||
|
converged as f64 / n as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
points.push(pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line.
|
||||||
|
let mut path = String::new();
|
||||||
|
for (i, &pct) in points.iter().enumerate() {
|
||||||
|
let x = margin_l + (i as f64 + 0.5) / rounds as f64 * chart_w;
|
||||||
|
let y = margin_t + chart_h - (pct / 100.0) * chart_h;
|
||||||
|
if i == 0 {
|
||||||
|
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||||
|
} else {
|
||||||
|
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<path d=\"{path}\" fill=\"none\" stroke=\"#4a90d9\" stroke-width=\"2\"/>\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Draw dots.
|
||||||
|
for (i, &pct) in points.iter().enumerate() {
|
||||||
|
let x = margin_l + (i as f64 + 0.5) / rounds as f64 * chart_w;
|
||||||
|
let y = margin_t + chart_h - (pct / 100.0) * chart_h;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"3\" fill=\"#4a90d9\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</svg>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message flow timeline ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_message_flow_svg(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
html.push_str("<h2>Message Flow Timeline</h2>\n");
|
||||||
|
html.push_str("<p>Arrows show Push messages from sender to receiver, grouped by round.</p>\n");
|
||||||
|
|
||||||
|
let n = trace.node_names.len();
|
||||||
|
let rounds = trace.num_rounds;
|
||||||
|
if n == 0 || rounds == 0 {
|
||||||
|
html.push_str("<p>No data.</p>\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_to_col: HashMap<&str, usize> = trace
|
||||||
|
.node_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (n.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Collect message arrows grouped by round.
|
||||||
|
let mut arrows_per_round: Vec<Vec<(usize, usize)>> = vec![Vec::new(); rounds];
|
||||||
|
for event in &trace.events {
|
||||||
|
if let GossipEventKind::PushReceived { ref from_name, .. } = event.kind {
|
||||||
|
let round_idx = event.tick.saturating_sub(1) as usize;
|
||||||
|
if round_idx < rounds {
|
||||||
|
if let (Some(&from_col), Some(&to_col)) = (
|
||||||
|
name_to_col.get(from_name.as_str()),
|
||||||
|
name_to_col.get(event.node_name.as_str()),
|
||||||
|
) {
|
||||||
|
arrows_per_round[round_idx].push((from_col, to_col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let col_w = 80.0_f64;
|
||||||
|
let row_h = 40.0_f64;
|
||||||
|
let header_h = 30.0_f64;
|
||||||
|
let label_h = 24.0_f64;
|
||||||
|
let svg_w = col_w * n as f64 + 40.0;
|
||||||
|
let svg_h = header_h + label_h + row_h * rounds as f64 + 20.0;
|
||||||
|
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<svg width=\"{svg_w:.0}\" height=\"{svg_h:.0}\" viewBox=\"0 0 {svg_w:.0} {svg_h:.0}\">\n"
|
||||||
|
));
|
||||||
|
html.push_str("<defs><marker id=\"flow-arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6\" fill=\"#d94a4a\"/></marker></defs>\n");
|
||||||
|
|
||||||
|
// Column headers (node names).
|
||||||
|
for (i, name) in trace.node_names.iter().enumerate() {
|
||||||
|
let x = 20.0 + i as f64 * col_w + col_w / 2.0;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"{x:.1}\" y=\"{label_h:.0}\" text-anchor=\"middle\" font-size=\"11\" font-weight=\"bold\" fill=\"#333\">{name}</text>\n"
|
||||||
|
));
|
||||||
|
// Vertical lifeline.
|
||||||
|
let y_start = header_h + label_h;
|
||||||
|
let y_end = header_h + label_h + row_h * rounds as f64;
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{x:.1}\" y1=\"{y_start:.0}\" x2=\"{x:.1}\" y2=\"{y_end:.0}\" stroke=\"#ddd\" stroke-width=\"1\" stroke-dasharray=\"4,3\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round labels and arrows.
|
||||||
|
for (r, arrows) in arrows_per_round.iter().enumerate() {
|
||||||
|
let y = header_h + label_h + r as f64 * row_h + row_h / 2.0;
|
||||||
|
// Round label on left.
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<text x=\"4\" y=\"{y:.1}\" font-size=\"9\" fill=\"#999\">R{}</text>\n",
|
||||||
|
r + 1,
|
||||||
|
));
|
||||||
|
|
||||||
|
for &(from_col, to_col) in arrows {
|
||||||
|
let x1 = 20.0 + from_col as f64 * col_w + col_w / 2.0;
|
||||||
|
let x2 = 20.0 + to_col as f64 * col_w + col_w / 2.0;
|
||||||
|
// Offset slightly so overlapping arrows are visible.
|
||||||
|
let offset = if from_col < to_col { -3.0 } else { 3.0 };
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"#d94a4a\" stroke-width=\"1.5\" marker-end=\"url(#flow-arrow)\"/>\n",
|
||||||
|
y1 = y + offset,
|
||||||
|
y2 = y + offset,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</svg>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event log table ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_event_table(html: &mut String, trace: &SimulationTrace) {
|
||||||
|
html.push_str("<h2>Event Log</h2>\n");
|
||||||
|
|
||||||
|
let max_rows = 500;
|
||||||
|
let events: Vec<_> = trace
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| !matches!(e.kind, GossipEventKind::StateSnapshot { .. }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = events.len();
|
||||||
|
let display = events.iter().take(max_rows);
|
||||||
|
|
||||||
|
html.push_str("<table>\n<tr><th>Round</th><th>Node</th><th>Event</th><th>Details</th></tr>\n");
|
||||||
|
for event in display {
|
||||||
|
let (kind_str, detail) = format_event_kind(&event.kind);
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td>{}</td><td>{kind_str}</td><td>{detail}</td></tr>\n",
|
||||||
|
event.tick,
|
||||||
|
escape_html(&event.node_name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</table>\n");
|
||||||
|
|
||||||
|
if total > max_rows {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<p class=\"capped\">Showing {max_rows} of {total} events.</p>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_event_kind(kind: &GossipEventKind) -> (&'static str, String) {
|
||||||
|
match kind {
|
||||||
|
GossipEventKind::LocalSet { key } => ("LocalSet", format!("key={}", escape_html(key))),
|
||||||
|
GossipEventKind::GossipRoundStarted { target_name } => {
|
||||||
|
("GossipRound", format!("→ {}", escape_html(target_name)))
|
||||||
|
}
|
||||||
|
GossipEventKind::GossipRoundNoPeers => ("GossipRound", "no peers".into()),
|
||||||
|
GossipEventKind::PushReceived {
|
||||||
|
from_name,
|
||||||
|
keys_updated,
|
||||||
|
} => (
|
||||||
|
"PushReceived",
|
||||||
|
format!(
|
||||||
|
"from {} ({keys_updated} updated)",
|
||||||
|
escape_html(from_name)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GossipEventKind::QueryReceived { key } => {
|
||||||
|
("Query", format!("key={}", escape_html(key)))
|
||||||
|
}
|
||||||
|
GossipEventKind::PeerAdded { peer_name } => {
|
||||||
|
("PeerAdded", escape_html(peer_name))
|
||||||
|
}
|
||||||
|
GossipEventKind::PeerRemoved { peer_name } => {
|
||||||
|
("PeerRemoved", escape_html(peer_name))
|
||||||
|
}
|
||||||
|
GossipEventKind::StateSnapshot { .. } => ("Snapshot", String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
394
crates/swactor-gossip/src/sim.rs
Normal file
394
crates/swactor-gossip/src/sim.rs
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::protocol::{GossipActor, GossipMessage};
|
||||||
|
use crate::trace::{
|
||||||
|
EventLog, GossipEventKind, NameRegistry, NodeSnapshot, SimulationTrace, TickCounter,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Topology {
|
||||||
|
/// Each node gossips to the next; last gossips to first.
|
||||||
|
Ring,
|
||||||
|
/// Node 0 is the hub; all others gossip to/from it.
|
||||||
|
Star,
|
||||||
|
/// Every node gossips to every other node.
|
||||||
|
FullMesh,
|
||||||
|
/// Unidirectional chain: 0→1→2→…→(n-1).
|
||||||
|
Chain,
|
||||||
|
/// Two halves with no cross-links (healed later via `heal_after_round`).
|
||||||
|
Partitioned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SimConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub topology: Topology,
|
||||||
|
pub num_nodes: usize,
|
||||||
|
/// `(key, value)` pairs to set on node 0 before gossip starts.
|
||||||
|
pub initial_data: Vec<(String, Vec<u8>)>,
|
||||||
|
pub num_rounds: usize,
|
||||||
|
pub ticks_per_round: usize,
|
||||||
|
/// If `Some(r)`, cross-partition links are added after round `r`.
|
||||||
|
pub heal_after_round: Option<usize>,
|
||||||
|
/// Number of worker threads: 1 = deterministic single-threaded, >1 = multi-threaded.
|
||||||
|
pub num_threads: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public entry point ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn run_simulation(config: SimConfig) -> SimulationTrace {
|
||||||
|
let num_threads = config.num_threads.max(1);
|
||||||
|
|
||||||
|
if num_threads < 2 {
|
||||||
|
run_simulation_single_threaded(config)
|
||||||
|
} else {
|
||||||
|
run_simulation_multi_threaded(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_simulation_single_threaded(config: SimConfig) -> SimulationTrace {
|
||||||
|
let event_log: EventLog = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let tick_counter: TickCounter = Arc::new(AtomicU64::new(0));
|
||||||
|
let name_registry: NameRegistry = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig {
|
||||||
|
num_threads: 1,
|
||||||
|
max_actors: (config.num_nodes + 64).next_power_of_two(),
|
||||||
|
actor_max_messages: (config.num_nodes * 4).max(1_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn nodes.
|
||||||
|
let mut addrs = Vec::with_capacity(config.num_nodes);
|
||||||
|
let mut names = Vec::with_capacity(config.num_nodes);
|
||||||
|
for i in 0..config.num_nodes {
|
||||||
|
let name = format!("node-{i}");
|
||||||
|
let actor = GossipActor::traced(
|
||||||
|
Arc::clone(&event_log),
|
||||||
|
Arc::clone(&tick_counter),
|
||||||
|
Arc::clone(&name_registry),
|
||||||
|
);
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
name_registry.lock().unwrap().insert(addr, name.clone());
|
||||||
|
addrs.push(addr);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire topology.
|
||||||
|
let edges = wire_topology(&rt, &config.topology, &addrs, &names);
|
||||||
|
// Deliver AddPeer messages.
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial data on node 0.
|
||||||
|
let total_keys = config.initial_data.len();
|
||||||
|
for (key, value) in &config.initial_data {
|
||||||
|
rt.send_to(
|
||||||
|
addrs[0],
|
||||||
|
GossipMessage::Set {
|
||||||
|
key: key.clone(),
|
||||||
|
value: value.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// Run gossip rounds.
|
||||||
|
let mut snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>> = Vec::new();
|
||||||
|
|
||||||
|
for round in 0..config.num_rounds {
|
||||||
|
// Heal partition if needed.
|
||||||
|
if config.heal_after_round == Some(round) {
|
||||||
|
heal_partition(&rt, &config.topology, &addrs, &names);
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick_counter.store((round + 1) as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Trigger gossip on all nodes.
|
||||||
|
for &addr in &addrs {
|
||||||
|
rt.send_to(addr, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..config.ticks_per_round {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take snapshots.
|
||||||
|
for &addr in &addrs {
|
||||||
|
rt.send_to(addr, GossipMessage::TakeSnapshot).unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract snapshots from event log.
|
||||||
|
let current_round_tick = (round + 1) as u64;
|
||||||
|
let log = event_log.lock().unwrap();
|
||||||
|
let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new();
|
||||||
|
for event in log.iter().rev() {
|
||||||
|
if event.tick != current_round_tick {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
|
||||||
|
round_snapshots.push((event.node_name.clone(), snapshot.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
round_snapshots.reverse();
|
||||||
|
snapshots_per_round.push(round_snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = event_log.lock().unwrap().clone();
|
||||||
|
|
||||||
|
SimulationTrace {
|
||||||
|
name: config.name,
|
||||||
|
node_names: names,
|
||||||
|
node_addrs: addrs,
|
||||||
|
topology_edges: edges,
|
||||||
|
events,
|
||||||
|
snapshots_per_round,
|
||||||
|
num_rounds: config.num_rounds,
|
||||||
|
total_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_simulation_multi_threaded(config: SimConfig) -> SimulationTrace {
|
||||||
|
let event_log: EventLog = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let tick_counter: TickCounter = Arc::new(AtomicU64::new(0));
|
||||||
|
let name_registry: NameRegistry = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let ticks_per_round = config.ticks_per_round;
|
||||||
|
// Safety factor for non-deterministic scheduling: allow more settle time.
|
||||||
|
let settle_ms = (ticks_per_round as u64 * 2).max(10);
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig {
|
||||||
|
num_threads: config.num_threads,
|
||||||
|
max_actors: (config.num_nodes + 64).next_power_of_two(),
|
||||||
|
actor_max_messages: (config.num_nodes * 4).max(1_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn nodes.
|
||||||
|
let mut addrs = Vec::with_capacity(config.num_nodes);
|
||||||
|
let mut names = Vec::with_capacity(config.num_nodes);
|
||||||
|
for i in 0..config.num_nodes {
|
||||||
|
let name = format!("node-{i}");
|
||||||
|
let actor = GossipActor::traced(
|
||||||
|
Arc::clone(&event_log),
|
||||||
|
Arc::clone(&tick_counter),
|
||||||
|
Arc::clone(&name_registry),
|
||||||
|
);
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
name_registry.lock().unwrap().insert(addr, name.clone());
|
||||||
|
addrs.push(addr);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire topology — send AddPeer messages before starting worker threads.
|
||||||
|
let edges = wire_topology(&rt, &config.topology, &addrs, &names);
|
||||||
|
|
||||||
|
// Set initial data on node 0 before starting.
|
||||||
|
let total_keys = config.initial_data.len();
|
||||||
|
for (key, value) in &config.initial_data {
|
||||||
|
rt.send_to(
|
||||||
|
addrs[0],
|
||||||
|
GossipMessage::Set {
|
||||||
|
key: key.clone(),
|
||||||
|
value: value.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start worker threads — consumes `rt`, returns handle.
|
||||||
|
let handle = rt.run().expect("failed to start multi-threaded runtime");
|
||||||
|
|
||||||
|
// Let initial messages (spawn + AddPeer + Set) settle.
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms * 2));
|
||||||
|
|
||||||
|
// Run gossip rounds using sleep-based ticking.
|
||||||
|
let mut snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>> = Vec::new();
|
||||||
|
|
||||||
|
for round in 0..config.num_rounds {
|
||||||
|
// Heal partition if needed.
|
||||||
|
if config.heal_after_round == Some(round) {
|
||||||
|
heal_partition_via_handle(&handle, &config.topology, &addrs, &names);
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
tick_counter.store((round + 1) as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Trigger gossip on all nodes.
|
||||||
|
for &addr in &addrs {
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addr, GossipMessage::DoGossipRound)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let gossip messages propagate.
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms));
|
||||||
|
|
||||||
|
// Take snapshots.
|
||||||
|
for &addr in &addrs {
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addr, GossipMessage::TakeSnapshot)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(settle_ms / 2));
|
||||||
|
|
||||||
|
// Extract snapshots from event log.
|
||||||
|
let current_round_tick = (round + 1) as u64;
|
||||||
|
let log = event_log.lock().unwrap();
|
||||||
|
let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new();
|
||||||
|
for event in log.iter().rev() {
|
||||||
|
if event.tick != current_round_tick {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
|
||||||
|
round_snapshots.push((event.node_name.clone(), snapshot.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
round_snapshots.reverse();
|
||||||
|
snapshots_per_round.push(round_snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown worker threads.
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
|
||||||
|
let events = event_log.lock().unwrap().clone();
|
||||||
|
|
||||||
|
SimulationTrace {
|
||||||
|
name: config.name,
|
||||||
|
node_names: names,
|
||||||
|
node_addrs: addrs,
|
||||||
|
topology_edges: edges,
|
||||||
|
events,
|
||||||
|
snapshots_per_round,
|
||||||
|
num_rounds: config.num_rounds,
|
||||||
|
total_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn heal_partition_via_handle(
|
||||||
|
handle: &swactor::runtime::RuntimeHandle,
|
||||||
|
topology: &Topology,
|
||||||
|
addrs: &[ActorAddress],
|
||||||
|
_names: &[String],
|
||||||
|
) {
|
||||||
|
if !matches!(topology, Topology::Partitioned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = addrs.len();
|
||||||
|
let half = n / 2;
|
||||||
|
if half > 0 && half < n {
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half]))
|
||||||
|
.unwrap();
|
||||||
|
handle
|
||||||
|
.runtime
|
||||||
|
.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topology wiring ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn wire_topology(
|
||||||
|
rt: &Runtime,
|
||||||
|
topology: &Topology,
|
||||||
|
addrs: &[ActorAddress],
|
||||||
|
names: &[String],
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let n = addrs.len();
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
|
||||||
|
let mut add_edge = |from: usize, to: usize| {
|
||||||
|
rt.send_to(addrs[from], GossipMessage::AddPeer(addrs[to]))
|
||||||
|
.unwrap();
|
||||||
|
edges.push((names[from].clone(), names[to].clone()));
|
||||||
|
};
|
||||||
|
|
||||||
|
match topology {
|
||||||
|
Topology::Ring => {
|
||||||
|
for i in 0..n {
|
||||||
|
add_edge(i, (i + 1) % n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Topology::Star => {
|
||||||
|
for i in 1..n {
|
||||||
|
add_edge(0, i);
|
||||||
|
add_edge(i, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Topology::FullMesh => {
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
if i != j {
|
||||||
|
add_edge(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Topology::Chain => {
|
||||||
|
for i in 0..n.saturating_sub(1) {
|
||||||
|
add_edge(i, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Topology::Partitioned => {
|
||||||
|
let half = n / 2;
|
||||||
|
// Wire each half as a full mesh.
|
||||||
|
for i in 0..half {
|
||||||
|
for j in 0..half {
|
||||||
|
if i != j {
|
||||||
|
add_edge(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in half..n {
|
||||||
|
for j in half..n {
|
||||||
|
if i != j {
|
||||||
|
add_edge(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edges
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn heal_partition(
|
||||||
|
rt: &Runtime,
|
||||||
|
topology: &Topology,
|
||||||
|
addrs: &[ActorAddress],
|
||||||
|
_names: &[String],
|
||||||
|
) {
|
||||||
|
if !matches!(topology, Topology::Partitioned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = addrs.len();
|
||||||
|
let half = n / 2;
|
||||||
|
// Add bidirectional links between the two halves (bridge nodes).
|
||||||
|
if half > 0 && half < n {
|
||||||
|
rt.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half]))
|
||||||
|
.unwrap();
|
||||||
|
rt.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/swactor-gossip/src/trace.rs
Normal file
101
crates/swactor-gossip/src/trace.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
|
||||||
|
use crate::protocol::VersionedValue;
|
||||||
|
|
||||||
|
// ── Shared handles ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Shared, append-only event log.
|
||||||
|
pub type EventLog = Arc<Mutex<Vec<GossipEvent>>>;
|
||||||
|
|
||||||
|
/// Shared tick counter — the simulation harness increments this.
|
||||||
|
pub type TickCounter = Arc<AtomicU64>;
|
||||||
|
|
||||||
|
/// Maps actor addresses to human-readable names like `"node-0"`.
|
||||||
|
pub type NameRegistry = Arc<Mutex<HashMap<ActorAddress, String>>>;
|
||||||
|
|
||||||
|
// ── TraceContext ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Bundles the three shared handles needed for tracing into one value.
|
||||||
|
pub struct TraceContext {
|
||||||
|
pub event_log: EventLog,
|
||||||
|
pub tick_counter: TickCounter,
|
||||||
|
pub name_registry: NameRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TraceContext {
|
||||||
|
pub fn current_tick(&self) -> u64 {
|
||||||
|
self.tick_counter.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_name(&self, addr: ActorAddress) -> String {
|
||||||
|
self.name_registry
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&addr)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("{:?}", &addr.0[..4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_event(&self, event: GossipEvent) {
|
||||||
|
self.event_log.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GossipEvent {
|
||||||
|
pub tick: u64,
|
||||||
|
pub node_name: String,
|
||||||
|
pub node_addr: ActorAddress,
|
||||||
|
pub thread_name: Option<String>,
|
||||||
|
pub kind: GossipEventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum GossipEventKind {
|
||||||
|
/// A local `Set { key, .. }` was processed.
|
||||||
|
LocalSet { key: String },
|
||||||
|
/// `DoGossipRound` chose a peer and sent a Push.
|
||||||
|
GossipRoundStarted { target_name: String },
|
||||||
|
/// `DoGossipRound` had no peers.
|
||||||
|
GossipRoundNoPeers,
|
||||||
|
/// Received a Push from another node.
|
||||||
|
PushReceived {
|
||||||
|
from_name: String,
|
||||||
|
keys_updated: usize,
|
||||||
|
},
|
||||||
|
/// Received a Query.
|
||||||
|
QueryReceived { key: String },
|
||||||
|
/// A peer was added.
|
||||||
|
PeerAdded { peer_name: String },
|
||||||
|
/// A peer was removed.
|
||||||
|
PeerRemoved { peer_name: String },
|
||||||
|
/// Full state snapshot (requested via `TakeSnapshot`).
|
||||||
|
StateSnapshot { snapshot: NodeSnapshot },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NodeSnapshot {
|
||||||
|
pub entries: HashMap<String, VersionedValue>,
|
||||||
|
pub peer_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulation trace (complete run output) ───────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SimulationTrace {
|
||||||
|
pub name: String,
|
||||||
|
pub node_names: Vec<String>,
|
||||||
|
pub node_addrs: Vec<ActorAddress>,
|
||||||
|
pub topology_edges: Vec<(String, String)>,
|
||||||
|
pub events: Vec<GossipEvent>,
|
||||||
|
pub snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>>,
|
||||||
|
pub num_rounds: usize,
|
||||||
|
pub total_keys: usize,
|
||||||
|
}
|
||||||
243
crates/swactor-gossip/tests/gossip_convergence.rs
Normal file
243
crates/swactor-gossip/tests/gossip_convergence.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
use swactor_gossip::{GossipActor, GossipMessage, GossipQueryResponse};
|
||||||
|
|
||||||
|
fn single_thread_runtime() -> Runtime {
|
||||||
|
Runtime::new(RuntimeConfig {
|
||||||
|
num_threads: 1,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive ticks until the inbox receives a response, or panic after a limit.
|
||||||
|
fn recv_query_response(
|
||||||
|
rt: &Runtime,
|
||||||
|
inbox: &swactor::runtime::Inbox<GossipQueryResponse>,
|
||||||
|
max_ticks: usize,
|
||||||
|
) -> GossipQueryResponse {
|
||||||
|
for _ in 0..max_ticks {
|
||||||
|
rt.tick();
|
||||||
|
if let Some(resp) = inbox.try_recv() {
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no GossipQueryResponse after {max_ticks} ticks");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 1: Value propagates through a chain A → B → C
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_propagates_through_chain() {
|
||||||
|
// Given: three gossip nodes wired A→B→C (each only gossips to the next)
|
||||||
|
let rt = single_thread_runtime();
|
||||||
|
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
let c = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::AddPeer(c)).unwrap();
|
||||||
|
rt.tick(); // deliver AddPeer messages
|
||||||
|
|
||||||
|
// When: we set a value on A and trigger gossip hops
|
||||||
|
rt.send_to(a, GossipMessage::Set {
|
||||||
|
key: "color".into(),
|
||||||
|
value: b"blue".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rt.tick(); // A processes Set
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.tick(); // A pushes to B
|
||||||
|
rt.tick(); // B processes Push
|
||||||
|
|
||||||
|
rt.send_to(b, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.tick(); // B pushes to C
|
||||||
|
rt.tick(); // C processes Push
|
||||||
|
|
||||||
|
// Then: querying C returns the value that originated at A
|
||||||
|
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||||
|
rt.send_to(c, GossipMessage::Query {
|
||||||
|
key: "color".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.key, "color");
|
||||||
|
assert_eq!(resp.value.as_deref(), Some(b"blue".as_slice()));
|
||||||
|
assert_eq!(resp.version, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 2: Higher version wins during merge
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn higher_version_wins() {
|
||||||
|
// Given: two nodes A and B, each set the same key at different versions
|
||||||
|
let rt = single_thread_runtime();
|
||||||
|
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// A sets "x" once (version 1)
|
||||||
|
rt.send_to(a, GossipMessage::Set {
|
||||||
|
key: "x".into(),
|
||||||
|
value: b"old".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// B sets "x" three times (version 3)
|
||||||
|
for val in [b"v1".as_slice(), b"v2", b"new"] {
|
||||||
|
rt.send_to(b, GossipMessage::Set {
|
||||||
|
key: "x".into(),
|
||||||
|
value: val.to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// When: A pushes its lower-version state to B
|
||||||
|
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.tick(); // A sends Push
|
||||||
|
rt.tick(); // B receives Push
|
||||||
|
|
||||||
|
// Then: B still has the higher-version value
|
||||||
|
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::Query {
|
||||||
|
key: "x".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.value.as_deref(), Some(b"new".as_slice()));
|
||||||
|
assert_eq!(resp.version, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 3: Disjoint keys merge — both nodes end up with both keys
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disjoint_keys_merge() {
|
||||||
|
// Given: A owns key "a", B owns key "b", they are mutual peers
|
||||||
|
let rt = single_thread_runtime();
|
||||||
|
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::AddPeer(a)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::Set {
|
||||||
|
key: "a".into(),
|
||||||
|
value: b"from-a".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::Set {
|
||||||
|
key: "b".into(),
|
||||||
|
value: b"from-b".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// When: both gossip to each other
|
||||||
|
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.tick(); // send Pushes
|
||||||
|
rt.tick(); // receive Pushes
|
||||||
|
|
||||||
|
// Then: A has key "b" and B has key "a"
|
||||||
|
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::Query {
|
||||||
|
key: "b".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.key, "b");
|
||||||
|
assert_eq!(resp.value.as_deref(), Some(b"from-b".as_slice()));
|
||||||
|
|
||||||
|
rt.send_to(b, GossipMessage::Query {
|
||||||
|
key: "a".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.key, "a");
|
||||||
|
assert_eq!(resp.value.as_deref(), Some(b"from-a".as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 4: Query for nonexistent key returns None
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_nonexistent_key_returns_none() {
|
||||||
|
// Given: a gossip node with no data
|
||||||
|
let rt = single_thread_runtime();
|
||||||
|
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// When: we query a key that was never set
|
||||||
|
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||||
|
rt.send_to(a, GossipMessage::Query {
|
||||||
|
key: "ghost".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Then: response has None value and None version
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.key, "ghost");
|
||||||
|
assert!(resp.value.is_none());
|
||||||
|
assert!(resp.version.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 5: Idempotent push — double-push doesn't bump versions
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idempotent_push() {
|
||||||
|
// Given: A has a key set, B is its peer
|
||||||
|
let rt = single_thread_runtime();
|
||||||
|
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
rt.send_to(a, GossipMessage::Set {
|
||||||
|
key: "k".into(),
|
||||||
|
value: b"val".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// When: A gossips to B twice (same state, same version)
|
||||||
|
for _ in 0..2 {
|
||||||
|
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||||
|
rt.tick(); // send Push
|
||||||
|
rt.tick(); // receive Push
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: B's version is still 1 (merge is idempotent, not additive)
|
||||||
|
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||||
|
rt.send_to(b, GossipMessage::Query {
|
||||||
|
key: "k".into(),
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = recv_query_response(&rt, &inbox, 10);
|
||||||
|
assert_eq!(resp.version, Some(1));
|
||||||
|
assert_eq!(resp.value.as_deref(), Some(b"val".as_slice()));
|
||||||
|
}
|
||||||
825
crates/swactor-gossip/tests/gossip_properties.rs
Normal file
825
crates/swactor-gossip/tests/gossip_properties.rs
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
10
src/actor.rs
10
src/actor.rs
|
|
@ -16,7 +16,17 @@ pub trait ActorInterface: 'static + Send {
|
||||||
/// but most systems are powerful, and this allows us to create a global map of
|
/// but most systems are powerful, and this allows us to create a global map of
|
||||||
/// actor processes in the future, without worrying about collision.
|
/// actor processes in the future, without worrying about collision.
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
pub struct ActorAddress(pub [u8; 32]);
|
pub struct ActorAddress(pub [u8; 32]);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ActorAddress {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
for b in &self.0[..8] {
|
||||||
|
write!(f, "{:02x}", b)?;
|
||||||
|
}
|
||||||
|
write!(f, "\u{2026}")
|
||||||
|
}
|
||||||
|
}
|
||||||
impl ActorAddress {
|
impl ActorAddress {
|
||||||
pub fn new_random() -> Self {
|
pub fn new_random() -> Self {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,10 @@ impl Runtime {
|
||||||
|
|
||||||
for mut worker in workers {
|
for mut worker in workers {
|
||||||
let rt_clone = rt.clone();
|
let rt_clone = rt.clone();
|
||||||
let handle = thread::spawn(move || {
|
let name = format!("swactor-worker-{}", worker.id.0);
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(name)
|
||||||
|
.spawn(move || {
|
||||||
let tc = TickContext {
|
let tc = TickContext {
|
||||||
address_map: &rt_clone.address_map,
|
address_map: &rt_clone.address_map,
|
||||||
transfer_txs: &rt_clone.transfer_txs,
|
transfer_txs: &rt_clone.transfer_txs,
|
||||||
|
|
@ -225,7 +228,8 @@ impl Runtime {
|
||||||
config: &rt_clone.config,
|
config: &rt_clone.config,
|
||||||
};
|
};
|
||||||
worker.run(&tc, &rt_clone.is_running);
|
worker.run(&tc, &rt_clone.is_running);
|
||||||
});
|
})
|
||||||
|
.expect("failed to spawn worker thread");
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use crate::Error;
|
||||||
|
|
||||||
/// A worker owns a set of actors and runs them in a loop.
|
/// A worker owns a set of actors and runs them in a loop.
|
||||||
pub(crate) struct Worker {
|
pub(crate) struct Worker {
|
||||||
id: WorkerId,
|
pub(crate) id: WorkerId,
|
||||||
pub(crate) pool: ActorPool,
|
pub(crate) pool: ActorPool,
|
||||||
transfer_rx: Receiver<Envelope>,
|
transfer_rx: Receiver<Envelope>,
|
||||||
spawn_rx: Receiver<(ActorAddress, Box<dyn AnyActor>)>,
|
spawn_rx: Receiver<(ActorAddress, Box<dyn AnyActor>)>,
|
||||||
|
|
|
||||||
15
tools/analyze.sh
Executable file
15
tools/analyze.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage: ./tools/analyze.sh <crate-src-dir> [output-dir]
|
||||||
|
# Example: ./tools/analyze.sh crates/swactor-gossip/src
|
||||||
|
|
||||||
|
SRC_DIR="${1:?Usage: $0 <crate-src-dir> [output-dir]}"
|
||||||
|
OUT_DIR="${2:-$(dirname "$SRC_DIR")/docs/connectome}"
|
||||||
|
|
||||||
|
cargo run --manifest-path tools/depgraph/Cargo.toml -- \
|
||||||
|
--src-dir "$SRC_DIR" --output-dir "$OUT_DIR"
|
||||||
|
|
||||||
|
uv run --with numpy --with scipy \
|
||||||
|
python tools/spectral/spectral_analysis.py \
|
||||||
|
"$OUT_DIR/deps.dot" --json --no-plots -o "$OUT_DIR"
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "depgraph"
|
name = "depgraph"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -54,31 +54,25 @@ struct Edge {
|
||||||
|
|
||||||
// ─── Module colors ───────────────────────────────────────────────────────────
|
// ─── Module colors ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn module_colors(module: &str) -> (&'static str, &'static str, &'static str) {
|
/// 8-color pastel palette for module clusters.
|
||||||
// Returns (cluster_fill, cluster_border, node_fill)
|
/// Each entry: (cluster_fill, cluster_border, node_fill)
|
||||||
match module {
|
const PALETTE: &[(&str, &str, &str)] = &[
|
||||||
"error" => ("#f0f0f0", "#888", "#e8f5e9"),
|
("#e3f2fd", "#1565c0", "#bbdefb"),
|
||||||
"config" => ("#f0f0f0", "#888", "#e8f5e9"),
|
("#fce4ec", "#c62828", "#ffcdd2"),
|
||||||
"channel" => ("#f0f0f0", "#888", "#fff9c4"),
|
("#fff3e0", "#e65100", "#ffe0b2"),
|
||||||
"actor" => ("#e3f2fd", "#1565c0", "#bbdefb"),
|
("#f3e5f5", "#7b1fa2", "#e1bee7"),
|
||||||
"address_map" => ("#f3e5f5", "#7b1fa2", "#e1bee7"),
|
("#e8f5e9", "#2e7d32", "#c8e6c9"),
|
||||||
"runtime" => ("#fce4ec", "#c62828", "#ffcdd2"),
|
("#fff9c4", "#f9a825", "#fff59d"),
|
||||||
"worker" => ("#fff3e0", "#e65100", "#ffe0b2"),
|
("#e0f7fa", "#00838f", "#b2ebf2"),
|
||||||
"python" => ("#f5f5f5", "#999", "#d7ccc8"),
|
("#fbe9e7", "#d84315", "#ffccbc"),
|
||||||
_ => ("#f0f0f0", "#888", "#e0e0e0"),
|
];
|
||||||
}
|
|
||||||
|
fn module_colors_by_index(index: usize) -> (&'static str, &'static str, &'static str) {
|
||||||
|
PALETTE[index % PALETTE.len()]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn module_edge_color(module: &str) -> &'static str {
|
fn module_edge_color_by_index(index: usize) -> &'static str {
|
||||||
match module {
|
PALETTE[index % PALETTE.len()].1
|
||||||
"error" | "config" | "channel" => "#666",
|
|
||||||
"actor" => "#1565c0",
|
|
||||||
"address_map" => "#7b1fa2",
|
|
||||||
"runtime" => "#c62828",
|
|
||||||
"worker" => "#e65100",
|
|
||||||
"python" => "#999",
|
|
||||||
_ => "#666",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 1: Module discovery ───────────────────────────────────────────────
|
// ─── Phase 1: Module discovery ───────────────────────────────────────────────
|
||||||
|
|
@ -827,22 +821,21 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
||||||
writeln!(out, " splines=ortho;").unwrap();
|
writeln!(out, " splines=ortho;").unwrap();
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
|
|
||||||
// Define module ordering for consistent output
|
// Use actual module names in discovery order for consistent output
|
||||||
let module_order = [
|
let module_order: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
|
||||||
"error",
|
|
||||||
"config",
|
// Build module_name → index lookup for palette rotation
|
||||||
"channel",
|
let module_index: HashMap<&str, usize> = module_order
|
||||||
"actor",
|
.iter()
|
||||||
"address_map",
|
.enumerate()
|
||||||
"runtime",
|
.map(|(i, &name)| (name, i))
|
||||||
"worker",
|
.collect();
|
||||||
"python",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Emit subgraph clusters
|
// Emit subgraph clusters
|
||||||
for mod_name in &module_order {
|
for (i, mod_name) in module_order.iter().enumerate() {
|
||||||
if let Some(module) = modules.iter().find(|m| m.name == *mod_name) {
|
if let Some(module) = modules.iter().find(|m| m.name == *mod_name) {
|
||||||
emit_cluster(&mut out, module);
|
let (cluster_fill, cluster_border, node_fill) = module_colors_by_index(i);
|
||||||
|
emit_cluster(&mut out, module, cluster_fill, cluster_border, node_fill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -866,7 +859,8 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
|
|
||||||
for edge in edges.iter().filter(|e| e.from_module == e.to_module) {
|
for edge in edges.iter().filter(|e| e.from_module == e.to_module) {
|
||||||
emit_edge(&mut out, edge, true);
|
let idx = module_index.get(edge.from_module.as_str()).copied().unwrap_or(0);
|
||||||
|
emit_edge(&mut out, edge, true, module_edge_color_by_index(idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit cross-module edges
|
// Emit cross-module edges
|
||||||
|
|
@ -928,7 +922,8 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for edge in edges_group {
|
for edge in edges_group {
|
||||||
emit_edge(&mut out, edge, false);
|
let idx = module_index.get(edge.from_module.as_str()).copied().unwrap_or(0);
|
||||||
|
emit_edge(&mut out, edge, false, module_edge_color_by_index(idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,8 +931,7 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_cluster(out: &mut String, module: &ModuleInfo) {
|
fn emit_cluster(out: &mut String, module: &ModuleInfo, cluster_fill: &str, cluster_border: &str, node_fill: &str) {
|
||||||
let (cluster_fill, cluster_border, node_fill) = module_colors(&module.name);
|
|
||||||
|
|
||||||
let style = if module.feature_gate.is_some() {
|
let style = if module.feature_gate.is_some() {
|
||||||
"rounded,dashed,filled"
|
"rounded,dashed,filled"
|
||||||
|
|
@ -1010,12 +1004,7 @@ fn emit_cluster(out: &mut String, module: &ModuleInfo) {
|
||||||
writeln!(out, " }}").unwrap();
|
writeln!(out, " }}").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_edge(out: &mut String, edge: &Edge, intra: bool) {
|
fn emit_edge(out: &mut String, edge: &Edge, intra: bool, color: &str) {
|
||||||
let color = if intra {
|
|
||||||
"#666"
|
|
||||||
} else {
|
|
||||||
module_edge_color(&edge.from_module)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (style, penwidth) = match edge.kind {
|
let (style, penwidth) = match edge.kind {
|
||||||
EdgeKind::TraitImpl => {
|
EdgeKind::TraitImpl => {
|
||||||
|
|
@ -1247,6 +1236,7 @@ fn main() {
|
||||||
|
|
||||||
let mut src_dir = PathBuf::from("src");
|
let mut src_dir = PathBuf::from("src");
|
||||||
let mut output_prefix = String::from("deps");
|
let mut output_prefix = String::from("deps");
|
||||||
|
let mut output_dir: Option<PathBuf> = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
|
|
@ -1259,10 +1249,15 @@ fn main() {
|
||||||
i += 1;
|
i += 1;
|
||||||
output_prefix = args[i].clone();
|
output_prefix = args[i].clone();
|
||||||
}
|
}
|
||||||
|
"--output-dir" => {
|
||||||
|
i += 1;
|
||||||
|
output_dir = Some(PathBuf::from(&args[i]));
|
||||||
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: depgraph [--src-dir src/] [--output deps]");
|
eprintln!("Usage: depgraph [--src-dir src/] [--output deps] [--output-dir DIR]");
|
||||||
eprintln!(" --src-dir DIR Source directory (default: src/)");
|
eprintln!(" --src-dir DIR Source directory (default: src/)");
|
||||||
eprintln!(" --output PREFIX Output prefix (default: deps)");
|
eprintln!(" --output PREFIX Output prefix (default: deps)");
|
||||||
|
eprintln!(" --output-dir DIR Directory for output files (default: cwd)");
|
||||||
eprintln!(" Produces PREFIX.dot and PREFIX.html");
|
eprintln!(" Produces PREFIX.dot and PREFIX.html");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
@ -1274,6 +1269,11 @@ fn main() {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists.
|
||||||
|
if let Some(ref dir) = output_dir {
|
||||||
|
fs::create_dir_all(dir).expect("Failed to create output directory");
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!("Scanning source directory: {}", src_dir.display());
|
eprintln!("Scanning source directory: {}", src_dir.display());
|
||||||
|
|
||||||
// Phase 1: Module discovery
|
// Phase 1: Module discovery
|
||||||
|
|
@ -1330,15 +1330,23 @@ fn main() {
|
||||||
|
|
||||||
// Phase 5: DOT output
|
// Phase 5: DOT output
|
||||||
let dot = generate_dot(&modules, &edges);
|
let dot = generate_dot(&modules, &edges);
|
||||||
let dot_path = format!("{}.dot", output_prefix);
|
let dot_file = format!("{}.dot", output_prefix);
|
||||||
|
let dot_path = match &output_dir {
|
||||||
|
Some(dir) => dir.join(&dot_file),
|
||||||
|
None => PathBuf::from(&dot_file),
|
||||||
|
};
|
||||||
fs::write(&dot_path, &dot).expect("Failed to write .dot file");
|
fs::write(&dot_path, &dot).expect("Failed to write .dot file");
|
||||||
eprintln!("Wrote {}", dot_path);
|
eprintln!("Wrote {}", dot_path.display());
|
||||||
|
|
||||||
// Phase 6: HTML output
|
// Phase 6: HTML output
|
||||||
let html = generate_html(&dot);
|
let html = generate_html(&dot);
|
||||||
let html_path = format!("{}.html", output_prefix);
|
let html_file = format!("{}.html", output_prefix);
|
||||||
|
let html_path = match &output_dir {
|
||||||
|
Some(dir) => dir.join(&html_file),
|
||||||
|
None => PathBuf::from(&html_file),
|
||||||
|
};
|
||||||
fs::write(&html_path, &html).expect("Failed to write .html file");
|
fs::write(&html_path, &html).expect("Failed to write .html file");
|
||||||
eprintln!("Wrote {}", html_path);
|
eprintln!("Wrote {}", html_path.display());
|
||||||
|
|
||||||
eprintln!("Done!");
|
eprintln!("Done!");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,20 @@ class ComplexityMetrics:
|
||||||
connected_components: int
|
connected_components: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructuralProperties:
|
||||||
|
avg_degree: float
|
||||||
|
max_fan_in: int
|
||||||
|
max_fan_in_node: str
|
||||||
|
max_fan_out: int
|
||||||
|
max_fan_out_node: str
|
||||||
|
dag_depth: int
|
||||||
|
clustering_coeff: float
|
||||||
|
module_cohesion: dict[str, float]
|
||||||
|
avg_module_cohesion: float
|
||||||
|
avg_module_size: float
|
||||||
|
|
||||||
|
|
||||||
# ─── DOT Parser ───────────────────────────────────────────────────────────────
|
# ─── DOT Parser ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def parse_dot(text: str) -> DependencyGraph:
|
def parse_dot(text: str) -> DependencyGraph:
|
||||||
|
|
@ -403,6 +417,117 @@ def compute_complexity_metrics(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Structural Properties ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _compute_dag_depth(A: np.ndarray) -> int:
|
||||||
|
"""Longest directed path in the graph."""
|
||||||
|
n = A.shape[0]
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
UNVISITED, VISITING, DONE = 0, 1, 2
|
||||||
|
state = [UNVISITED] * n
|
||||||
|
depth = [0] * n
|
||||||
|
|
||||||
|
def dfs(node: int) -> int:
|
||||||
|
if state[node] == DONE:
|
||||||
|
return depth[node]
|
||||||
|
if state[node] == VISITING:
|
||||||
|
return 0 # cycle — treat as leaf
|
||||||
|
state[node] = VISITING
|
||||||
|
best = 0
|
||||||
|
for j in range(n):
|
||||||
|
if A[node, j] > 0:
|
||||||
|
best = max(best, 1 + dfs(j))
|
||||||
|
state[node] = DONE
|
||||||
|
depth[node] = best
|
||||||
|
return best
|
||||||
|
|
||||||
|
return max(dfs(i) for i in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_clustering_coefficient(A_sym: np.ndarray) -> float:
|
||||||
|
"""Global clustering coefficient (transitivity) on the undirected graph.
|
||||||
|
|
||||||
|
Uses the matrix identity: C = trace(A³) / (||A²||₁ - trace(A²))
|
||||||
|
where ||·||₁ is the sum of all elements.
|
||||||
|
"""
|
||||||
|
n = A_sym.shape[0]
|
||||||
|
if n < 3:
|
||||||
|
return 0.0
|
||||||
|
A2 = A_sym @ A_sym
|
||||||
|
A3 = A2 @ A_sym
|
||||||
|
numerator = np.trace(A3)
|
||||||
|
denominator = A2.sum() - np.trace(A2)
|
||||||
|
if denominator == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(numerator / denominator)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_structural_properties(
|
||||||
|
graph: DependencyGraph,
|
||||||
|
spectral: SpectralResults,
|
||||||
|
) -> StructuralProperties:
|
||||||
|
"""Compute graph-theoretic structural properties."""
|
||||||
|
n = len(graph.nodes)
|
||||||
|
n_edges = len(graph.edges)
|
||||||
|
node_names = spectral.node_names
|
||||||
|
A = spectral.adjacency
|
||||||
|
|
||||||
|
avg_degree = n_edges / n if n > 0 else 0.0
|
||||||
|
|
||||||
|
in_degrees = A.sum(axis=0)
|
||||||
|
out_degrees = A.sum(axis=1)
|
||||||
|
|
||||||
|
if n > 0:
|
||||||
|
fi_idx = int(np.argmax(in_degrees))
|
||||||
|
fo_idx = int(np.argmax(out_degrees))
|
||||||
|
max_fan_in = int(in_degrees[fi_idx])
|
||||||
|
max_fan_out = int(out_degrees[fo_idx])
|
||||||
|
max_fan_in_node = node_names[fi_idx]
|
||||||
|
max_fan_out_node = node_names[fo_idx]
|
||||||
|
else:
|
||||||
|
max_fan_in = max_fan_out = 0
|
||||||
|
max_fan_in_node = max_fan_out_node = ""
|
||||||
|
|
||||||
|
dag_depth = _compute_dag_depth(A)
|
||||||
|
clustering_coeff = _compute_clustering_coefficient(spectral.adjacency_sym)
|
||||||
|
|
||||||
|
# Per-module cohesion: intra-edges / max-possible-intra-edges
|
||||||
|
module_cohesion: dict[str, float] = {}
|
||||||
|
module_sizes: dict[str, int] = {}
|
||||||
|
for mod in graph.modules:
|
||||||
|
mod_nodes = [i for i, name in enumerate(node_names)
|
||||||
|
if graph.node_to_module.get(name) == mod]
|
||||||
|
k = len(mod_nodes)
|
||||||
|
module_sizes[mod] = k
|
||||||
|
if k <= 1:
|
||||||
|
module_cohesion[mod] = float("nan")
|
||||||
|
continue
|
||||||
|
max_possible = k * (k - 1)
|
||||||
|
actual = sum(1 for i in mod_nodes for j in mod_nodes
|
||||||
|
if i != j and A[i, j] > 0)
|
||||||
|
module_cohesion[mod] = actual / max_possible
|
||||||
|
|
||||||
|
valid = [v for v in module_cohesion.values() if not math.isnan(v)]
|
||||||
|
avg_cohesion = sum(valid) / len(valid) if valid else 0.0
|
||||||
|
|
||||||
|
sizes = list(module_sizes.values())
|
||||||
|
avg_size = sum(sizes) / len(sizes) if sizes else 0.0
|
||||||
|
|
||||||
|
return StructuralProperties(
|
||||||
|
avg_degree=avg_degree,
|
||||||
|
max_fan_in=max_fan_in,
|
||||||
|
max_fan_in_node=max_fan_in_node,
|
||||||
|
max_fan_out=max_fan_out,
|
||||||
|
max_fan_out_node=max_fan_out_node,
|
||||||
|
dag_depth=dag_depth,
|
||||||
|
clustering_coeff=clustering_coeff,
|
||||||
|
module_cohesion=module_cohesion,
|
||||||
|
avg_module_cohesion=avg_cohesion,
|
||||||
|
avg_module_size=avg_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Full Pipeline ────────────────────────────────────────────────────────────
|
# ─── Full Pipeline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -411,6 +536,7 @@ class AnalysisResult:
|
||||||
spectral: SpectralResults
|
spectral: SpectralResults
|
||||||
coupling: ModuleCouplingResult
|
coupling: ModuleCouplingResult
|
||||||
metrics: ComplexityMetrics
|
metrics: ComplexityMetrics
|
||||||
|
structural: StructuralProperties
|
||||||
|
|
||||||
|
|
||||||
def run_analysis(graph: DependencyGraph) -> AnalysisResult:
|
def run_analysis(graph: DependencyGraph) -> AnalysisResult:
|
||||||
|
|
@ -418,11 +544,13 @@ def run_analysis(graph: DependencyGraph) -> AnalysisResult:
|
||||||
spectral = compute_spectral(graph)
|
spectral = compute_spectral(graph)
|
||||||
coupling = compute_module_coupling(graph)
|
coupling = compute_module_coupling(graph)
|
||||||
metrics = compute_complexity_metrics(spectral, coupling)
|
metrics = compute_complexity_metrics(spectral, coupling)
|
||||||
|
structural = compute_structural_properties(graph, spectral)
|
||||||
return AnalysisResult(
|
return AnalysisResult(
|
||||||
graph=graph,
|
graph=graph,
|
||||||
spectral=spectral,
|
spectral=spectral,
|
||||||
coupling=coupling,
|
coupling=coupling,
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
|
structural=structural,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -433,6 +561,7 @@ def generate_report(result: AnalysisResult) -> str:
|
||||||
s = result.spectral
|
s = result.spectral
|
||||||
m = result.metrics
|
m = result.metrics
|
||||||
c = result.coupling
|
c = result.coupling
|
||||||
|
p = result.structural
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
def w(text: str = "") -> None:
|
def w(text: str = "") -> None:
|
||||||
|
|
@ -453,36 +582,28 @@ def generate_report(result: AnalysisResult) -> str:
|
||||||
w(f" Modules: {', '.join(c.module_names)}")
|
w(f" Modules: {', '.join(c.module_names)}")
|
||||||
w()
|
w()
|
||||||
|
|
||||||
# Eigenvalue spectrum
|
# Structural properties
|
||||||
w("LAPLACIAN EIGENVALUE SPECTRUM")
|
w("STRUCTURAL PROPERTIES")
|
||||||
w("-" * 40)
|
w("-" * 40)
|
||||||
for i, ev in enumerate(s.eigenvalues):
|
w(f" Edges/node (avg degree): {p.avg_degree:.2f}")
|
||||||
marker = " <-- Fiedler value (lambda_2)" if i == 1 else ""
|
w(f" Max fan-in: {p.max_fan_in:<4d} ({p.max_fan_in_node})")
|
||||||
w(f" lambda_{i:2d} = {ev:8.4f}{marker}")
|
w(f" Max fan-out: {p.max_fan_out:<4d} ({p.max_fan_out_node})")
|
||||||
w()
|
w(f" DAG depth: {p.dag_depth}")
|
||||||
if len(s.eigenvalues) > 1:
|
w(f" Clustering coefficient: {p.clustering_coeff:.4f}")
|
||||||
spectral_gap = float(s.eigenvalues[-1] - s.eigenvalues[1])
|
|
||||||
w(f" Spectral gap (lambda_max - lambda_2): {spectral_gap:.4f}")
|
|
||||||
w(f" Fiedler value (algebraic connectivity): {s.fiedler_value:.4f}")
|
|
||||||
w()
|
w()
|
||||||
|
|
||||||
# Fiedler vector analysis
|
# Module cohesion
|
||||||
if len(s.fiedler_vector) > 0:
|
w("MODULE COHESION")
|
||||||
w("FIEDLER VECTOR — SPECTRAL BISECTION")
|
|
||||||
w("-" * 40)
|
w("-" * 40)
|
||||||
# Sort by fiedler value
|
w(f" {'Module':<16s} {'Size':>5s} {'Cohesion':>8s}")
|
||||||
indices = np.argsort(s.fiedler_vector)
|
for mod in c.module_names:
|
||||||
w(" Partition A (Fiedler < 0):")
|
coh = p.module_cohesion.get(mod, float("nan"))
|
||||||
for idx in indices:
|
size = sum(1 for n in result.graph.nodes if n.module == mod)
|
||||||
if s.fiedler_vector[idx] < 0:
|
coh_str = f"{coh:.3f}" if not math.isnan(coh) else " —"
|
||||||
w(f" {s.node_names[idx]:25s} [{s.node_modules[idx]:12s}] "
|
w(f" {mod:<16s} {size:>5d} {coh_str:>8s}")
|
||||||
f"f = {s.fiedler_vector[idx]:+.4f}")
|
w(f" {'─' * 32}")
|
||||||
w(" ────────────────────────────────────")
|
w(f" {'Average cohesion:':<22s} {p.avg_module_cohesion:8.3f}")
|
||||||
w(" Partition B (Fiedler >= 0):")
|
w(f" {'Avg module size:':<22s} {p.avg_module_size:8.1f}")
|
||||||
for idx in indices:
|
|
||||||
if s.fiedler_vector[idx] >= 0:
|
|
||||||
w(f" {s.node_names[idx]:25s} [{s.node_modules[idx]:12s}] "
|
|
||||||
f"f = {s.fiedler_vector[idx]:+.4f}")
|
|
||||||
w()
|
w()
|
||||||
|
|
||||||
# Module coupling
|
# Module coupling
|
||||||
|
|
@ -545,23 +666,28 @@ def generate_report(result: AnalysisResult) -> str:
|
||||||
|
|
||||||
# ─── Dashboard Visualization ─────────────────────────────────────────────────
|
# ─── Dashboard Visualization ─────────────────────────────────────────────────
|
||||||
|
|
||||||
# Module colors matching the depgraph tool
|
# Module border colors from the depgraph palette (used as the accent color).
|
||||||
MODULE_COLORS = {
|
# These rotate by discovery-order index; the palette has 8 entries.
|
||||||
"error": "#4caf50",
|
_PALETTE_BORDER = [
|
||||||
"config": "#8bc34a",
|
"#1565c0", # 0 — blue
|
||||||
"channel": "#ffeb3b",
|
"#c62828", # 1 — red
|
||||||
"actor": "#2196f3",
|
"#e65100", # 2 — orange
|
||||||
"address_map": "#9c27b0",
|
"#7b1fa2", # 3 — purple
|
||||||
"runtime": "#f44336",
|
"#2e7d32", # 4 — green
|
||||||
"worker": "#ff9800",
|
"#f9a825", # 5 — yellow
|
||||||
"python": "#795548",
|
"#00838f", # 6 — teal
|
||||||
}
|
"#d84315", # 7 — deep orange
|
||||||
|
]
|
||||||
|
|
||||||
DEFAULT_COLOR = "#9e9e9e"
|
# Module index assigned at analysis time (populated by generate_dashboard_html)
|
||||||
|
_module_index: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_module_color(module: str) -> str:
|
def get_module_color(module: str) -> str:
|
||||||
return MODULE_COLORS.get(module, DEFAULT_COLOR)
|
idx = _module_index.get(module)
|
||||||
|
if idx is not None:
|
||||||
|
return _PALETTE_BORDER[idx % len(_PALETTE_BORDER)]
|
||||||
|
return "#9e9e9e"
|
||||||
|
|
||||||
|
|
||||||
def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
||||||
|
|
@ -571,6 +697,11 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from matplotlib.gridspec import GridSpec
|
from matplotlib.gridspec import GridSpec
|
||||||
|
|
||||||
|
# Ensure module palette indices are populated
|
||||||
|
_module_index.clear()
|
||||||
|
for i, mod in enumerate(result.graph.modules):
|
||||||
|
_module_index[mod] = i
|
||||||
|
|
||||||
s = result.spectral
|
s = result.spectral
|
||||||
m = result.metrics
|
m = result.metrics
|
||||||
c = result.coupling
|
c = result.coupling
|
||||||
|
|
@ -595,63 +726,52 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
||||||
fig.suptitle("Spectral Analysis Dashboard — Dependency DAG",
|
fig.suptitle("Spectral Analysis Dashboard — Dependency DAG",
|
||||||
fontsize=16, fontweight="bold", color="#e0e0e0")
|
fontsize=16, fontweight="bold", color="#e0e0e0")
|
||||||
|
|
||||||
# ── Top-left: Eigenvalue spectrum ──
|
p = result.structural
|
||||||
|
|
||||||
|
# ── Top-left: Structural properties ──
|
||||||
ax1 = fig.add_subplot(gs[0, 0])
|
ax1 = fig.add_subplot(gs[0, 0])
|
||||||
n = len(s.eigenvalues)
|
ax1.axis("off")
|
||||||
colors_eig = ["#ff4444" if i == 1 else "#4fc3f7" for i in range(n)]
|
ax1.set_title("Structural Properties", fontsize=12, fontweight="bold")
|
||||||
markerline, stemlines, baseline = ax1.stem(
|
props = [
|
||||||
range(n), s.eigenvalues, linefmt="-", markerfmt="o", basefmt=" "
|
("Edges/node (avg degree)", f"{p.avg_degree:.2f}"),
|
||||||
)
|
("Max fan-in", f"{p.max_fan_in} ({p.max_fan_in_node})"),
|
||||||
markerline.set_color("#4fc3f7")
|
("Max fan-out", f"{p.max_fan_out} ({p.max_fan_out_node})"),
|
||||||
markerline.set_markersize(5)
|
("DAG depth", f"{p.dag_depth}"),
|
||||||
stemlines.set_color("#4fc3f7")
|
("Clustering coefficient", f"{p.clustering_coeff:.4f}"),
|
||||||
stemlines.set_alpha(0.6)
|
("Avg module size", f"{p.avg_module_size:.1f}"),
|
||||||
# Highlight lambda_2
|
("Avg module cohesion", f"{p.avg_module_cohesion:.3f}"),
|
||||||
if n > 1:
|
]
|
||||||
ax1.plot(1, s.eigenvalues[1], "o", color="#ff4444", markersize=10,
|
y = 0.88
|
||||||
zorder=5, label=f"$\\lambda_2$ = {s.fiedler_value:.4f}")
|
for label, value in props:
|
||||||
ax1.legend(fontsize=10, loc="upper left",
|
ax1.text(0.05, y, label, transform=ax1.transAxes, fontsize=10,
|
||||||
facecolor="#16213e", edgecolor="#444")
|
color="#aaa", fontfamily="monospace", va="top")
|
||||||
ax1.set_xlabel("Index")
|
ax1.text(0.95, y, value, transform=ax1.transAxes, fontsize=10,
|
||||||
ax1.set_ylabel("Eigenvalue")
|
fontweight="bold", color="#e0e0e0", fontfamily="monospace",
|
||||||
ax1.set_title("Laplacian Eigenvalue Spectrum", fontsize=12, fontweight="bold")
|
va="top", ha="right")
|
||||||
ax1.grid(True, alpha=0.3)
|
y -= 0.12
|
||||||
|
|
||||||
# ── Top-right: Fiedler vector ──
|
# ── Top-right: Module cohesion ──
|
||||||
ax2 = fig.add_subplot(gs[0, 1])
|
ax2 = fig.add_subplot(gs[0, 1])
|
||||||
if len(s.fiedler_vector) > 0:
|
cohesion_mods = [mod for mod in c.module_names
|
||||||
sorted_indices = np.argsort(s.fiedler_vector)
|
if not math.isnan(p.module_cohesion.get(mod, float("nan")))]
|
||||||
sorted_values = s.fiedler_vector[sorted_indices]
|
if cohesion_mods:
|
||||||
sorted_names = [s.node_names[i] for i in sorted_indices]
|
cohesion_vals = [p.module_cohesion[mod] for mod in cohesion_mods]
|
||||||
sorted_modules = [s.node_modules[i] for i in sorted_indices]
|
bar_colors = [get_module_color(mod) for mod in cohesion_mods]
|
||||||
bar_colors = [get_module_color(mod) for mod in sorted_modules]
|
bars = ax2.barh(range(len(cohesion_mods)), cohesion_vals,
|
||||||
|
color=bar_colors, edgecolor="none", height=0.6)
|
||||||
bars = ax2.barh(range(len(sorted_values)), sorted_values,
|
ax2.set_yticks(range(len(cohesion_mods)))
|
||||||
color=bar_colors, edgecolor="none", height=0.8)
|
ax2.set_yticklabels(cohesion_mods, fontsize=9)
|
||||||
ax2.axvline(x=0, color="#ff4444", linewidth=1.5, linestyle="--",
|
ax2.set_xlim(0, 1.05)
|
||||||
alpha=0.8, label="Bisection boundary")
|
ax2.set_xlabel("Cohesion (intra-edges / max possible)")
|
||||||
ax2.set_yticks(range(len(sorted_names)))
|
ax2.axvline(x=p.avg_module_cohesion, color="#ff4444", linewidth=1.5,
|
||||||
ax2.set_yticklabels(sorted_names, fontsize=6)
|
linestyle="--", alpha=0.7, label=f"avg = {p.avg_module_cohesion:.3f}")
|
||||||
ax2.set_xlabel("Fiedler value")
|
ax2.legend(fontsize=9, loc="lower right",
|
||||||
ax2.set_title("Fiedler Vector (spectral bisection)", fontsize=12,
|
facecolor="#16213e", edgecolor="#444")
|
||||||
fontweight="bold")
|
ax2.grid(True, axis="x", alpha=0.3)
|
||||||
|
|
||||||
# Legend for modules
|
|
||||||
unique_modules = []
|
|
||||||
seen = set()
|
|
||||||
for mod in sorted_modules:
|
|
||||||
if mod not in seen:
|
|
||||||
seen.add(mod)
|
|
||||||
unique_modules.append(mod)
|
|
||||||
from matplotlib.patches import Patch
|
|
||||||
legend_patches = [Patch(facecolor=get_module_color(mod), label=mod)
|
|
||||||
for mod in unique_modules]
|
|
||||||
ax2.legend(handles=legend_patches, fontsize=7, loc="lower right",
|
|
||||||
facecolor="#16213e", edgecolor="#444", ncol=2)
|
|
||||||
else:
|
else:
|
||||||
ax2.text(0.5, 0.5, "No Fiedler vector\n(single node graph)",
|
ax2.text(0.5, 0.5, "No modules with 2+ types",
|
||||||
ha="center", va="center", fontsize=14, transform=ax2.transAxes)
|
ha="center", va="center", fontsize=14, transform=ax2.transAxes)
|
||||||
ax2.set_title("Fiedler Vector", fontsize=12, fontweight="bold")
|
ax2.set_title("Module Cohesion", fontsize=12, fontweight="bold")
|
||||||
|
|
||||||
# ── Bottom-left: Module coupling heatmap ──
|
# ── Bottom-left: Module coupling heatmap ──
|
||||||
ax3 = fig.add_subplot(gs[1, 0])
|
ax3 = fig.add_subplot(gs[1, 0])
|
||||||
|
|
@ -746,25 +866,40 @@ def generate_dashboard_html(
|
||||||
m = result.metrics
|
m = result.metrics
|
||||||
c = result.coupling
|
c = result.coupling
|
||||||
|
|
||||||
# Prepare data as JSON for embedding
|
p = result.structural
|
||||||
sorted_indices = list(np.argsort(s.fiedler_vector)) if len(s.fiedler_vector) > 0 else []
|
|
||||||
fiedler_data = []
|
|
||||||
for idx in sorted_indices:
|
|
||||||
fiedler_data.append({
|
|
||||||
"name": s.node_names[idx],
|
|
||||||
"module": s.node_modules[idx],
|
|
||||||
"value": float(s.fiedler_vector[idx]),
|
|
||||||
})
|
|
||||||
|
|
||||||
eigenvalue_data = [{"index": i, "value": float(v)}
|
# Prepare data as JSON for embedding
|
||||||
for i, v in enumerate(s.eigenvalues)]
|
structural_data = {
|
||||||
|
"avg_degree": round(p.avg_degree, 2),
|
||||||
|
"max_fan_in": p.max_fan_in,
|
||||||
|
"max_fan_in_node": p.max_fan_in_node,
|
||||||
|
"max_fan_out": p.max_fan_out,
|
||||||
|
"max_fan_out_node": p.max_fan_out_node,
|
||||||
|
"dag_depth": p.dag_depth,
|
||||||
|
"clustering_coeff": round(p.clustering_coeff, 4),
|
||||||
|
"avg_module_cohesion": round(p.avg_module_cohesion, 3),
|
||||||
|
"avg_module_size": round(p.avg_module_size, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
cohesion_data = []
|
||||||
|
for mod in c.module_names:
|
||||||
|
coh = p.module_cohesion.get(mod, float("nan"))
|
||||||
|
if not math.isnan(coh):
|
||||||
|
cohesion_data.append({
|
||||||
|
"module": mod,
|
||||||
|
"cohesion": round(coh, 3),
|
||||||
|
"size": sum(1 for n in result.graph.nodes if n.module == mod),
|
||||||
|
})
|
||||||
|
|
||||||
coupling_data = {
|
coupling_data = {
|
||||||
"modules": c.module_names,
|
"modules": c.module_names,
|
||||||
"matrix": c.coupling_matrix.tolist(),
|
"matrix": c.coupling_matrix.tolist(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Module colors
|
# Module colors — populate index from discovery order so palette rotates
|
||||||
|
_module_index.clear()
|
||||||
|
for i, mod in enumerate(result.graph.modules):
|
||||||
|
_module_index[mod] = i
|
||||||
all_modules = list(dict.fromkeys(n.module for n in result.graph.nodes))
|
all_modules = list(dict.fromkeys(n.module for n in result.graph.nodes))
|
||||||
module_colors_json = {mod: get_module_color(mod) for mod in all_modules}
|
module_colors_json = {mod: get_module_color(mod) for mod in all_modules}
|
||||||
|
|
||||||
|
|
@ -799,12 +934,11 @@ def generate_dashboard_html(
|
||||||
"cci_label": cci_label,
|
"cci_label": cci_label,
|
||||||
"cci_color": cci_color,
|
"cci_color": cci_color,
|
||||||
"cci_desc": cci_desc,
|
"cci_desc": cci_desc,
|
||||||
"fiedler_value": round(s.fiedler_value, 4),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data_blob = json.dumps({
|
data_blob = json.dumps({
|
||||||
"eigenvalues": eigenvalue_data,
|
"structural": structural_data,
|
||||||
"fiedler": fiedler_data,
|
"cohesion": cohesion_data,
|
||||||
"coupling": coupling_data,
|
"coupling": coupling_data,
|
||||||
"metrics": metrics_json,
|
"metrics": metrics_json,
|
||||||
"module_colors": module_colors_json,
|
"module_colors": module_colors_json,
|
||||||
|
|
@ -901,11 +1035,7 @@ svg text { user-select:none; }
|
||||||
.hm-cell { cursor:pointer; transition:opacity 0.15s; }
|
.hm-cell { cursor:pointer; transition:opacity 0.15s; }
|
||||||
.hm-cell:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; }
|
.hm-cell:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; }
|
||||||
|
|
||||||
/* Eigenvalue bars */
|
/* Cohesion / heatmap bars */
|
||||||
.ev-bar { cursor:pointer; transition:opacity 0.15s; }
|
|
||||||
.ev-bar:hover { opacity:0.8; }
|
|
||||||
|
|
||||||
/* Fiedler bars */
|
|
||||||
.fi-bar { cursor:pointer; transition:opacity 0.15s; }
|
.fi-bar { cursor:pointer; transition:opacity 0.15s; }
|
||||||
.fi-bar:hover { opacity:0.85; }
|
.fi-bar:hover { opacity:0.85; }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -914,11 +1044,11 @@ svg text { user-select:none; }
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<div class="title">swactor — dependency analysis</div>
|
<div class="title">swactor — dependency analysis</div>
|
||||||
<button class="tab active" data-tab="dag">Dependency DAG</button>
|
<button class="tab active" data-tab="spectral">Spectral Analysis</button>
|
||||||
<button class="tab" data-tab="spectral">Spectral Analysis</button>
|
<button class="tab" data-tab="dag">Dependency DAG</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content active" id="tab-dag">
|
<div class="tab-content" id="tab-dag">
|
||||||
<div id="dag-controls">
|
<div id="dag-controls">
|
||||||
<button onclick="zoomIn()">+</button>
|
<button onclick="zoomIn()">+</button>
|
||||||
<button onclick="zoomOut()">−</button>
|
<button onclick="zoomOut()">−</button>
|
||||||
|
|
@ -929,16 +1059,16 @@ svg text { user-select:none; }
|
||||||
<div id="dag-loading">Loading Graphviz…</div>
|
<div id="dag-loading">Loading Graphviz…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content" id="tab-spectral">
|
<div class="tab-content active" id="tab-spectral">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="panel" id="panel-eigenvalues">
|
<div class="panel" id="panel-structural">
|
||||||
<h2><span class="icon">λ</span> Laplacian Eigenvalue Spectrum</h2>
|
<h2><span class="icon">◉</span> Structural Properties</h2>
|
||||||
<svg id="svg-eigenvalues"></svg>
|
<div id="structural-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" id="panel-fiedler">
|
<div class="panel" id="panel-cohesion">
|
||||||
<h2><span class="icon">✂</span> Fiedler Vector — Spectral Bisection</h2>
|
<h2><span class="icon">▨</span> Module Cohesion</h2>
|
||||||
<svg id="svg-fiedler"></svg>
|
<svg id="svg-cohesion"></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" id="panel-heatmap">
|
<div class="panel" id="panel-heatmap">
|
||||||
|
|
@ -959,7 +1089,7 @@ svg text { user-select:none; }
|
||||||
<script>
|
<script>
|
||||||
// ─── Data ──────────────────────────────────────────────────────────────────
|
// ─── Data ──────────────────────────────────────────────────────────────────
|
||||||
const DATA = __DATA_BLOB__;
|
const DATA = __DATA_BLOB__;
|
||||||
const { eigenvalues, fiedler, coupling, metrics, module_colors } = DATA;
|
const { structural, cohesion, coupling, metrics, module_colors } = DATA;
|
||||||
|
|
||||||
// ─── Tab switching ─────────────────────────────────────────────────────────
|
// ─── Tab switching ─────────────────────────────────────────────────────────
|
||||||
document.querySelectorAll('.tab').forEach(btn => {
|
document.querySelectorAll('.tab').forEach(btn => {
|
||||||
|
|
@ -968,6 +1098,9 @@ document.querySelectorAll('.tab').forEach(btn => {
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||||
|
if (btn.dataset.tab === 'dag') {
|
||||||
|
window.dispatchEvent(new Event('dag-visible'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -984,138 +1117,99 @@ function hideTip() { TT.style.display = 'none'; }
|
||||||
|
|
||||||
function modColor(mod) { return module_colors[mod] || '#9e9e9e'; }
|
function modColor(mod) { return module_colors[mod] || '#9e9e9e'; }
|
||||||
|
|
||||||
// ─── Eigenvalue Spectrum ───────────────────────────────────────────────────
|
// ─── Structural Properties ────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
const svg = document.getElementById('svg-eigenvalues');
|
const c = document.getElementById('structural-content');
|
||||||
const W = 560, H = 300, M = {t:20,r:20,b:40,l:50};
|
const s = structural;
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="sub-header">Density & Depth</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Edges/node (avg degree)</span><span class="metric-value">${s.avg_degree}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">DAG depth</span><span class="metric-value">${s.dag_depth}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Clustering coefficient</span><span class="metric-value">${s.clustering_coeff}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Avg module size</span><span class="metric-value">${s.avg_module_size}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Dependency Hotspots</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Max fan-in</span><span class="metric-value">${s.max_fan_in} ← ${s.max_fan_in_node}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Max fan-out</span><span class="metric-value">${s.max_fan_out} → ${s.max_fan_out_node}</span></div>
|
||||||
|
|
||||||
|
<div class="sub-header">Cohesion</div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Avg module cohesion</span><span class="metric-value">${s.avg_module_cohesion}</span></div>
|
||||||
|
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(metrics.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Module Cohesion ──────────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const svg = document.getElementById('svg-cohesion');
|
||||||
|
const n = cohesion.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const barH = Math.max(20, Math.min(36, 300/n));
|
||||||
|
const W = 560, H = Math.max(200, n*barH + 60), M = {t:10,r:30,b:30,l:120};
|
||||||
const w = W-M.l-M.r, h = H-M.t-M.b;
|
const w = W-M.l-M.r, h = H-M.t-M.b;
|
||||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||||
|
|
||||||
const maxVal = Math.max(...eigenvalues.map(d=>d.value), 1);
|
const xScale = v => M.l + v * w;
|
||||||
const xScale = i => M.l + (i / (eigenvalues.length-1||1)) * w;
|
const yScale = i => M.t + (i/n) * h + barH/2;
|
||||||
const yScale = v => M.t + h - (v / maxVal) * h;
|
|
||||||
|
|
||||||
// Grid lines
|
// Background grid
|
||||||
for (let tick = 0; tick <= maxVal; tick += Math.ceil(maxVal/5)) {
|
for (const tick of [0.25, 0.5, 0.75, 1.0]) {
|
||||||
const y = yScale(tick);
|
const x = xScale(tick);
|
||||||
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||||
Object.entries({x1:M.l,x2:W-M.r,y1:y,y2:y,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
Object.entries({x1:x,x2:x,y1:M.t,y2:M.t+h,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||||
svg.appendChild(line);
|
svg.appendChild(line);
|
||||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
txt.setAttribute('x', M.l-6); txt.setAttribute('y', y+4);
|
txt.setAttribute('x', x); txt.setAttribute('y', H-8);
|
||||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill','#888'); txt.setAttribute('font-size','10');
|
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#666'); txt.setAttribute('font-size','10');
|
||||||
txt.textContent = tick.toFixed(0);
|
txt.textContent = (tick*100).toFixed(0) + '%';
|
||||||
svg.appendChild(txt);
|
svg.appendChild(txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Axis labels
|
// Average line
|
||||||
const xLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
const avgX = xScale(structural.avg_module_cohesion);
|
||||||
xLabel.setAttribute('x', M.l+w/2); xLabel.setAttribute('y', H-4);
|
const avgLine = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||||
xLabel.setAttribute('text-anchor','middle'); xLabel.setAttribute('fill','#888'); xLabel.setAttribute('font-size','11');
|
Object.entries({x1:avgX,x2:avgX,y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>avgLine.setAttribute(k,v));
|
||||||
xLabel.textContent = 'Index';
|
svg.appendChild(avgLine);
|
||||||
svg.appendChild(xLabel);
|
const avgLbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
avgLbl.setAttribute('x', avgX+4); avgLbl.setAttribute('y', M.t+10);
|
||||||
const yLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
avgLbl.setAttribute('fill','#ff4444'); avgLbl.setAttribute('font-size','9'); avgLbl.setAttribute('opacity','0.8');
|
||||||
yLabel.setAttribute('x', 14); yLabel.setAttribute('y', M.t+h/2);
|
avgLbl.textContent = 'avg';
|
||||||
yLabel.setAttribute('text-anchor','middle'); yLabel.setAttribute('fill','#888');
|
svg.appendChild(avgLbl);
|
||||||
yLabel.setAttribute('font-size','11'); yLabel.setAttribute('transform', `rotate(-90,14,${M.t+h/2})`);
|
|
||||||
yLabel.textContent = 'Eigenvalue';
|
|
||||||
svg.appendChild(yLabel);
|
|
||||||
|
|
||||||
eigenvalues.forEach((d, i) => {
|
|
||||||
const x = xScale(i), y = yScale(d.value), y0 = yScale(0);
|
|
||||||
// Stem line
|
|
||||||
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
|
||||||
Object.entries({x1:x,x2:x,y1:y0,y2:y,stroke:i===1?'#ff4444':'#4fc3f7','stroke-width':i===1?2.5:1.5,'stroke-opacity':i===1?1:0.6}).forEach(([k,v])=>line.setAttribute(k,v));
|
|
||||||
svg.appendChild(line);
|
|
||||||
// Dot
|
|
||||||
const circ = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
|
||||||
circ.setAttribute('cx',x); circ.setAttribute('cy',y);
|
|
||||||
circ.setAttribute('r', i===1?6:3.5);
|
|
||||||
circ.setAttribute('fill', i===1?'#ff4444':'#4fc3f7');
|
|
||||||
circ.classList.add('ev-bar');
|
|
||||||
circ.addEventListener('mousemove', e => showTip(e,
|
|
||||||
`<span class="tt-label">λ<sub>${i}</sub></span> = <span class="tt-val">${d.value.toFixed(4)}</span>`
|
|
||||||
+ (i===1 ? '<br><span style="color:#ff4444">Fiedler value (algebraic connectivity)</span>' : '')
|
|
||||||
));
|
|
||||||
circ.addEventListener('mouseleave', hideTip);
|
|
||||||
svg.appendChild(circ);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fiedler label
|
|
||||||
if (eigenvalues.length > 1) {
|
|
||||||
const lbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
|
||||||
lbl.setAttribute('x', xScale(1)+10); lbl.setAttribute('y', yScale(eigenvalues[1].value)-6);
|
|
||||||
lbl.setAttribute('fill','#ff4444'); lbl.setAttribute('font-size','11'); lbl.setAttribute('font-weight','600');
|
|
||||||
lbl.textContent = `\u03BB\u2082 = ${metrics.fiedler_value}`;
|
|
||||||
svg.appendChild(lbl);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── Fiedler Vector ────────────────────────────────────────────────────────
|
|
||||||
(function() {
|
|
||||||
const svg = document.getElementById('svg-fiedler');
|
|
||||||
const n = fiedler.length;
|
|
||||||
const barH = Math.max(12, Math.min(22, 500/n));
|
|
||||||
const W = 560, H = Math.max(300, n*barH + 60), M = {t:10,r:20,b:30,l:140};
|
|
||||||
const w = W-M.l-M.r, h = H-M.t-M.b;
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
||||||
|
|
||||||
const maxAbs = Math.max(...fiedler.map(d=>Math.abs(d.value)), 0.01);
|
|
||||||
const xScale = v => M.l + w/2 + (v/maxAbs) * (w/2);
|
|
||||||
const yScale = i => M.t + (i/n) * h + barH/2;
|
|
||||||
|
|
||||||
// Zero line
|
|
||||||
const zl = document.createElementNS('http://www.w3.org/2000/svg','line');
|
|
||||||
Object.entries({x1:xScale(0),x2:xScale(0),y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>zl.setAttribute(k,v));
|
|
||||||
svg.appendChild(zl);
|
|
||||||
|
|
||||||
// Partition labels
|
|
||||||
const negCount = fiedler.filter(d=>d.value<0).length;
|
|
||||||
if (negCount > 0 && negCount < n) {
|
|
||||||
const lblA = document.createElementNS('http://www.w3.org/2000/svg','text');
|
|
||||||
lblA.setAttribute('x', M.l+4); lblA.setAttribute('y', M.t + (negCount/n)*h/2 + barH/2);
|
|
||||||
lblA.setAttribute('fill','#ff4444'); lblA.setAttribute('font-size','10'); lblA.setAttribute('opacity','0.5');
|
|
||||||
lblA.textContent = 'Partition A';
|
|
||||||
svg.appendChild(lblA);
|
|
||||||
}
|
|
||||||
|
|
||||||
fiedler.forEach((d, i) => {
|
|
||||||
const x0 = xScale(0), x1 = xScale(d.value);
|
|
||||||
const y = yScale(i) - barH*0.4;
|
|
||||||
const bw = Math.abs(x1-x0);
|
|
||||||
|
|
||||||
|
cohesion.forEach((d, i) => {
|
||||||
|
const barW = Math.max(d.cohesion * w, 2);
|
||||||
|
const y = yScale(i) - barH*0.35;
|
||||||
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||||
rect.setAttribute('x', Math.min(x0,x1)); rect.setAttribute('y', y);
|
rect.setAttribute('x', M.l); rect.setAttribute('y', y);
|
||||||
rect.setAttribute('width', Math.max(bw, 1)); rect.setAttribute('height', barH*0.8);
|
rect.setAttribute('width', barW); rect.setAttribute('height', barH*0.7);
|
||||||
rect.setAttribute('rx', 2);
|
rect.setAttribute('rx', 3);
|
||||||
rect.setAttribute('fill', modColor(d.module));
|
rect.setAttribute('fill', modColor(d.module));
|
||||||
rect.setAttribute('opacity', 0.85);
|
rect.setAttribute('opacity', 0.85);
|
||||||
rect.classList.add('fi-bar');
|
rect.classList.add('fi-bar');
|
||||||
rect.addEventListener('mousemove', e => showTip(e,
|
rect.addEventListener('mousemove', e => showTip(e,
|
||||||
`<span class="tt-label">${d.name}</span><br>` +
|
`<span class="tt-label">${d.module}</span><br>` +
|
||||||
`Module: <span class="tt-val">${d.module}</span><br>` +
|
`Types: <span class="tt-val">${d.size}</span><br>` +
|
||||||
`Fiedler: <span class="tt-val">${d.value.toFixed(4)}</span><br>` +
|
`Cohesion: <span class="tt-val">${(d.cohesion*100).toFixed(1)}%</span>`
|
||||||
`Partition: <span class="tt-val">${d.value < 0 ? 'A' : 'B'}</span>`
|
|
||||||
));
|
));
|
||||||
rect.addEventListener('mouseleave', hideTip);
|
rect.addEventListener('mouseleave', hideTip);
|
||||||
svg.appendChild(rect);
|
svg.appendChild(rect);
|
||||||
|
|
||||||
// Label
|
// Value label on bar
|
||||||
|
const valTxt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
valTxt.setAttribute('x', M.l + barW + 6); valTxt.setAttribute('y', yScale(i)+4);
|
||||||
|
valTxt.setAttribute('fill','#ccc'); valTxt.setAttribute('font-size','10'); valTxt.setAttribute('font-weight','600');
|
||||||
|
valTxt.textContent = (d.cohesion*100).toFixed(0) + '%';
|
||||||
|
svg.appendChild(valTxt);
|
||||||
|
|
||||||
|
// Module label
|
||||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
txt.setAttribute('x', M.l-4); txt.setAttribute('y', yScale(i)+3);
|
txt.setAttribute('x', M.l-8); txt.setAttribute('y', yScale(i)+4);
|
||||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill','#ccc');
|
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill', modColor(d.module));
|
||||||
txt.setAttribute('font-size', Math.min(11, barH*0.75));
|
txt.setAttribute('font-size','11'); txt.setAttribute('font-weight','600');
|
||||||
txt.textContent = d.name;
|
txt.textContent = `${d.module} (${d.size})`;
|
||||||
svg.appendChild(txt);
|
svg.appendChild(txt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// X axis label
|
|
||||||
const xLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
|
||||||
xLabel.setAttribute('x', M.l+w/2); xLabel.setAttribute('y', H-6);
|
|
||||||
xLabel.setAttribute('text-anchor','middle'); xLabel.setAttribute('fill','#888'); xLabel.setAttribute('font-size','11');
|
|
||||||
xLabel.textContent = 'Fiedler value';
|
|
||||||
svg.appendChild(xLabel);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── Module Coupling Heatmap ───────────────────────────────────────────────
|
// ─── Module Coupling Heatmap ───────────────────────────────────────────────
|
||||||
|
|
@ -1250,7 +1344,9 @@ vp.appendChild(svg);
|
||||||
|
|
||||||
// ─── Dark-mode SVG recoloring ──────────────────────────────────────────────
|
// ─── Dark-mode SVG recoloring ──────────────────────────────────────────────
|
||||||
svg.querySelectorAll('polygon[fill="white"]').forEach(el => el.setAttribute('fill','#1a1a2e'));
|
svg.querySelectorAll('polygon[fill="white"]').forEach(el => el.setAttribute('fill','#1a1a2e'));
|
||||||
svg.querySelectorAll('.graph > text, .cluster > text, .edge text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
svg.querySelectorAll('.graph > text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
||||||
|
svg.querySelectorAll('.cluster > text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||||
|
svg.querySelectorAll('.edge text').forEach(el => el.setAttribute('fill','#ffb74d'));
|
||||||
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||||
|
|
||||||
// ─── Click-to-focus ────────────────────────────────────────────────────────
|
// ─── Click-to-focus ────────────────────────────────────────────────────────
|
||||||
|
|
@ -1347,7 +1443,10 @@ window.resetView = function() {
|
||||||
ty = (vh - bb.height * scale) / 2;
|
ty = (vh - bb.height * scale) / 2;
|
||||||
applyTransform();
|
applyTransform();
|
||||||
};
|
};
|
||||||
resetView();
|
let dagFitted = false;
|
||||||
|
window.addEventListener('dag-visible', () => {
|
||||||
|
if (!dagFitted) { dagFitted = true; requestAnimationFrame(resetView); }
|
||||||
|
});
|
||||||
|
|
||||||
window.zoomIn = function() { scale *= 1.3; applyTransform(); };
|
window.zoomIn = function() { scale *= 1.3; applyTransform(); };
|
||||||
window.zoomOut = function() { scale *= 0.7; applyTransform(); };
|
window.zoomOut = function() { scale *= 0.7; applyTransform(); };
|
||||||
|
|
@ -1367,8 +1466,8 @@ vp.addEventListener('click', e => { if (!didDrag && !e.target.closest('.node'))
|
||||||
def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
||||||
"""Convert analysis results to a JSON-serializable dict."""
|
"""Convert analysis results to a JSON-serializable dict."""
|
||||||
m = result.metrics
|
m = result.metrics
|
||||||
s = result.spectral
|
|
||||||
c = result.coupling
|
c = result.coupling
|
||||||
|
p = result.structural
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"graph": {
|
"graph": {
|
||||||
|
|
@ -1378,12 +1477,13 @@ def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
||||||
"connected_components": m.connected_components,
|
"connected_components": m.connected_components,
|
||||||
"modules": c.module_names,
|
"modules": c.module_names,
|
||||||
},
|
},
|
||||||
"spectral": {
|
"structural": {
|
||||||
"eigenvalues": s.eigenvalues.tolist(),
|
"avg_degree": p.avg_degree,
|
||||||
"fiedler_value": s.fiedler_value,
|
"max_fan_in": {"count": p.max_fan_in, "node": p.max_fan_in_node},
|
||||||
"fiedler_vector": s.fiedler_vector.tolist(),
|
"max_fan_out": {"count": p.max_fan_out, "node": p.max_fan_out_node},
|
||||||
"node_names": s.node_names,
|
"dag_depth": p.dag_depth,
|
||||||
"node_modules": s.node_modules,
|
"clustering_coefficient": p.clustering_coeff,
|
||||||
|
"avg_module_size": p.avg_module_size,
|
||||||
},
|
},
|
||||||
"module_coupling": {
|
"module_coupling": {
|
||||||
"module_names": c.module_names,
|
"module_names": c.module_names,
|
||||||
|
|
@ -1391,15 +1491,17 @@ def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
||||||
"cross_module_edges": c.cross_module_edges,
|
"cross_module_edges": c.cross_module_edges,
|
||||||
"total_edges": c.total_edges,
|
"total_edges": c.total_edges,
|
||||||
},
|
},
|
||||||
|
"module_cohesion": {
|
||||||
|
mod: None if math.isnan(v) else v
|
||||||
|
for mod, v in p.module_cohesion.items()
|
||||||
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"algebraic_connectivity": m.algebraic_connectivity,
|
"algebraic_connectivity": m.algebraic_connectivity,
|
||||||
"normalized_algebraic_connectivity": m.normalized_algebraic_connectivity,
|
|
||||||
"spectral_entropy": m.spectral_entropy,
|
"spectral_entropy": m.spectral_entropy,
|
||||||
"normalized_spectral_entropy": m.normalized_spectral_entropy,
|
|
||||||
"edge_density": m.edge_density,
|
"edge_density": m.edge_density,
|
||||||
"cross_module_ratio": m.cross_module_ratio,
|
"cross_module_ratio": m.cross_module_ratio,
|
||||||
"spectral_radius": m.spectral_radius,
|
"spectral_radius": m.spectral_radius,
|
||||||
"normalized_spectral_radius": m.normalized_spectral_radius,
|
"avg_module_cohesion": p.avg_module_cohesion,
|
||||||
"cci": m.cci,
|
"cci": m.cci,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -686,11 +686,12 @@ class TestIntegration(unittest.TestCase):
|
||||||
d = metrics_to_dict(result)
|
d = metrics_to_dict(result)
|
||||||
|
|
||||||
self.assertIn("graph", d)
|
self.assertIn("graph", d)
|
||||||
self.assertIn("spectral", d)
|
self.assertIn("structural", d)
|
||||||
self.assertIn("module_coupling", d)
|
self.assertIn("module_coupling", d)
|
||||||
|
self.assertIn("module_cohesion", d)
|
||||||
self.assertIn("metrics", d)
|
self.assertIn("metrics", d)
|
||||||
self.assertEqual(d["graph"]["n_nodes"], 3)
|
self.assertEqual(d["graph"]["n_nodes"], 3)
|
||||||
self.assertIsInstance(d["spectral"]["eigenvalues"], list)
|
self.assertIsInstance(d["structural"]["dag_depth"], int)
|
||||||
self.assertIsInstance(d["metrics"]["cci"], float)
|
self.assertIsInstance(d["metrics"]["cci"], float)
|
||||||
|
|
||||||
# Should be JSON-serializable
|
# Should be JSON-serializable
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue