diff --git a/.gitignore b/.gitignore index 7291f84..66fa602 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__ fuzz/artifacts/** corpus + +# Analysis artifacts (depgraph + spectral) +**/deps.dot +**/deps.html diff --git a/crates/swactor-gossip/docs/connectome/connectome_dashboard.html b/crates/swactor-gossip/docs/connectome/connectome_dashboard.html new file mode 100644 index 0000000..aff9e34 --- /dev/null +++ b/crates/swactor-gossip/docs/connectome/connectome_dashboard.html @@ -0,0 +1,568 @@ + + + +swactor — dependency analysis + + + + +
+
swactor — dependency analysis
+ + +
+ +
+
+ + + + scroll to zoom · drag to pan · click node to focus +
+
+
Loading Graphviz…
+
+ +
+
+
+

◉ Structural Properties

+
+
+ +
+

▨ Module Cohesion

+ +
+ +
+

▦ Module Coupling (directed edge counts)

+ +
+ +
+

∑ Complexity Metrics

+
+
+
+
+ +
+ + + + + + + diff --git a/crates/swactor-gossip/docs/connectome/connectome_metrics.json b/crates/swactor-gossip/docs/connectome/connectome_metrics.json new file mode 100644 index 0000000..f2f41b7 --- /dev/null +++ b/crates/swactor-gossip/docs/connectome/connectome_metrics.json @@ -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 + } +} \ No newline at end of file diff --git a/crates/swactor-gossip/docs/connectome/connectome_report.txt b/crates/swactor-gossip/docs/connectome/connectome_report.txt new file mode 100644 index 0000000..c314409 --- /dev/null +++ b/crates/swactor-gossip/docs/connectome/connectome_report.txt @@ -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 + +======================================================================== \ No newline at end of file diff --git a/crates/swactor-gossip/src/lib.rs b/crates/swactor-gossip/src/lib.rs index 381ef56..f61729a 100644 --- a/crates/swactor-gossip/src/lib.rs +++ b/crates/swactor-gossip/src/lib.rs @@ -1,11 +1,7 @@ -pub mod actor; -pub mod message; -pub mod state; +pub mod protocol; pub mod trace; pub mod report; pub mod sim; -pub use actor::GossipActor; -pub use message::{GossipMessage, GossipQueryResponse}; -pub use state::{GossipState, VersionedValue}; +pub use protocol::{GossipActor, GossipMessage, GossipQueryResponse}; diff --git a/crates/swactor-gossip/src/message.rs b/crates/swactor-gossip/src/message.rs deleted file mode 100644 index afee4ca..0000000 --- a/crates/swactor-gossip/src/message.rs +++ /dev/null @@ -1,34 +0,0 @@ -use swactor::actor::ActorAddress; - -use crate::state::GossipState; - -#[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 }, - /// 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, -} - -#[derive(Debug, Clone)] -pub struct GossipQueryResponse { - pub key: String, - pub value: Option>, - pub version: Option, -} diff --git a/crates/swactor-gossip/src/actor.rs b/crates/swactor-gossip/src/protocol.rs similarity index 51% rename from crates/swactor-gossip/src/actor.rs rename to crates/swactor-gossip/src/protocol.rs index d72d1e3..bc80775 100644 --- a/crates/swactor-gossip/src/actor.rs +++ b/crates/swactor-gossip/src/protocol.rs @@ -1,18 +1,114 @@ +use std::collections::HashMap; + use swactor::actor::{ActorAddress, ActorInterface, Ctx}; -use crate::message::{GossipMessage, GossipQueryResponse}; -use crate::state::GossipState; -use crate::trace::{ - current_tick, record_event, resolve_name, EventLog, GossipEvent, GossipEventKind, - NameRegistry, NodeSnapshot, TickCounter, -}; +use crate::trace::{GossipEvent, GossipEventKind, NodeSnapshot, TraceContext}; + +// ── VersionedValue ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct VersionedValue { + pub value: Vec, + pub version: u64, +} + +// ── GossipState ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct GossipState { + entries: HashMap, +} + +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) -> 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 { + &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>, + pub version: Option, +} + +// ── 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 }, + /// 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, - event_log: Option, - tick_counter: Option, - name_registry: Option, + trace: Option, } impl GossipActor { @@ -20,20 +116,24 @@ impl GossipActor { Self { state: GossipState::new(), peers: Vec::new(), - event_log: None, - tick_counter: None, - name_registry: None, + trace: None, } } /// Create a traced actor that records events into the shared log. - pub fn traced(log: EventLog, tick: TickCounter, names: NameRegistry) -> Self { + pub fn traced( + log: crate::trace::EventLog, + tick: crate::trace::TickCounter, + names: crate::trace::NameRegistry, + ) -> Self { Self { state: GossipState::new(), peers: Vec::new(), - event_log: Some(log), - tick_counter: Some(tick), - name_registry: Some(names), + trace: Some(TraceContext { + event_log: log, + tick_counter: tick, + name_registry: names, + }), } } @@ -48,16 +148,14 @@ impl GossipActor { } fn record(&self, addr: ActorAddress, kind: GossipEventKind) { - if let (Some(log), Some(tick), Some(names)) = - (&self.event_log, &self.tick_counter, &self.name_registry) - { + if let Some(trace) = &self.trace { let event = GossipEvent { - tick: current_tick(tick), - node_name: resolve_name(names, addr), + tick: trace.current_tick(), + node_name: trace.resolve_name(addr), node_addr: addr, kind, }; - record_event(log, event); + trace.record_event(event); } } } @@ -82,9 +180,9 @@ impl ActorInterface for GossipActor { self_addr, GossipEventKind::PeerAdded { peer_name: self - .name_registry + .trace .as_ref() - .map(|r| resolve_name(r, addr)) + .map(|t| t.resolve_name(addr)) .unwrap_or_default(), }, ); @@ -98,9 +196,9 @@ impl ActorInterface for GossipActor { self_addr, GossipEventKind::PeerRemoved { peer_name: self - .name_registry + .trace .as_ref() - .map(|r| resolve_name(r, addr)) + .map(|t| t.resolve_name(addr)) .unwrap_or_default(), }, ); @@ -116,9 +214,9 @@ impl ActorInterface for GossipActor { self_addr, GossipEventKind::GossipRoundStarted { target_name: self - .name_registry + .trace .as_ref() - .map(|r| resolve_name(r, peer)) + .map(|t| t.resolve_name(peer)) .unwrap_or_default(), }, ); @@ -142,9 +240,9 @@ impl ActorInterface for GossipActor { self_addr, GossipEventKind::PushReceived { from_name: self - .name_registry + .trace .as_ref() - .map(|r| resolve_name(r, from)) + .map(|t| t.resolve_name(from)) .unwrap_or_default(), keys_updated, }, diff --git a/crates/swactor-gossip/src/sim.rs b/crates/swactor-gossip/src/sim.rs index 6c3e74f..4e6e04e 100644 --- a/crates/swactor-gossip/src/sim.rs +++ b/crates/swactor-gossip/src/sim.rs @@ -6,8 +6,7 @@ use swactor::actor::ActorAddress; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; -use crate::actor::GossipActor; -use crate::message::GossipMessage; +use crate::protocol::{GossipActor, GossipMessage}; use crate::trace::{ EventLog, GossipEventKind, NameRegistry, NodeSnapshot, SimulationTrace, TickCounter, }; diff --git a/crates/swactor-gossip/src/state.rs b/crates/swactor-gossip/src/state.rs deleted file mode 100644 index 44a2cc7..0000000 --- a/crates/swactor-gossip/src/state.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::collections::HashMap; - -#[derive(Debug, Clone)] -pub struct VersionedValue { - pub value: Vec, - pub version: u64, -} - -#[derive(Debug, Clone, Default)] -pub struct GossipState { - entries: HashMap, -} - -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) -> 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 { - &self.entries - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// 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 - } -} diff --git a/crates/swactor-gossip/src/trace.rs b/crates/swactor-gossip/src/trace.rs index 2f155d5..47a5e9d 100644 --- a/crates/swactor-gossip/src/trace.rs +++ b/crates/swactor-gossip/src/trace.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use swactor::actor::ActorAddress; -use crate::state::VersionedValue; +use crate::protocol::VersionedValue; // ── Shared handles ─────────────────────────────────────────────────────── @@ -17,6 +17,34 @@ pub type TickCounter = Arc; /// Maps actor addresses to human-readable names like `"node-0"`. pub type NameRegistry = Arc>>; +// ── 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)] @@ -69,22 +97,3 @@ pub struct SimulationTrace { pub num_rounds: usize, pub total_keys: usize, } - -// ── Helpers ────────────────────────────────────────────────────────────── - -pub fn current_tick(counter: &TickCounter) -> u64 { - counter.load(Ordering::Relaxed) -} - -pub fn resolve_name(registry: &NameRegistry, addr: ActorAddress) -> String { - registry - .lock() - .unwrap() - .get(&addr) - .cloned() - .unwrap_or_else(|| format!("{:?}", &addr.0[..4])) -} - -pub fn record_event(log: &EventLog, event: GossipEvent) { - log.lock().unwrap().push(event); -} diff --git a/tools/analyze.sh b/tools/analyze.sh new file mode 100755 index 0000000..535c929 --- /dev/null +++ b/tools/analyze.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./tools/analyze.sh [output-dir] +# Example: ./tools/analyze.sh crates/swactor-gossip/src + +SRC_DIR="${1:?Usage: $0 [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" diff --git a/tools/depgraph/Cargo.toml b/tools/depgraph/Cargo.toml index cbb06df..92138cf 100644 --- a/tools/depgraph/Cargo.toml +++ b/tools/depgraph/Cargo.toml @@ -1,3 +1,5 @@ +[workspace] + [package] name = "depgraph" version = "0.1.0" diff --git a/tools/depgraph/src/main.rs b/tools/depgraph/src/main.rs index 2662195..2b95073 100644 --- a/tools/depgraph/src/main.rs +++ b/tools/depgraph/src/main.rs @@ -54,31 +54,25 @@ struct Edge { // ─── Module colors ─────────────────────────────────────────────────────────── -fn module_colors(module: &str) -> (&'static str, &'static str, &'static str) { - // Returns (cluster_fill, cluster_border, node_fill) - match module { - "error" => ("#f0f0f0", "#888", "#e8f5e9"), - "config" => ("#f0f0f0", "#888", "#e8f5e9"), - "channel" => ("#f0f0f0", "#888", "#fff9c4"), - "actor" => ("#e3f2fd", "#1565c0", "#bbdefb"), - "address_map" => ("#f3e5f5", "#7b1fa2", "#e1bee7"), - "runtime" => ("#fce4ec", "#c62828", "#ffcdd2"), - "worker" => ("#fff3e0", "#e65100", "#ffe0b2"), - "python" => ("#f5f5f5", "#999", "#d7ccc8"), - _ => ("#f0f0f0", "#888", "#e0e0e0"), - } +/// 8-color pastel palette for module clusters. +/// Each entry: (cluster_fill, cluster_border, node_fill) +const PALETTE: &[(&str, &str, &str)] = &[ + ("#e3f2fd", "#1565c0", "#bbdefb"), + ("#fce4ec", "#c62828", "#ffcdd2"), + ("#fff3e0", "#e65100", "#ffe0b2"), + ("#f3e5f5", "#7b1fa2", "#e1bee7"), + ("#e8f5e9", "#2e7d32", "#c8e6c9"), + ("#fff9c4", "#f9a825", "#fff59d"), + ("#e0f7fa", "#00838f", "#b2ebf2"), + ("#fbe9e7", "#d84315", "#ffccbc"), +]; + +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 { - match module { - "error" | "config" | "channel" => "#666", - "actor" => "#1565c0", - "address_map" => "#7b1fa2", - "runtime" => "#c62828", - "worker" => "#e65100", - "python" => "#999", - _ => "#666", - } +fn module_edge_color_by_index(index: usize) -> &'static str { + PALETTE[index % PALETTE.len()].1 } // ─── Phase 1: Module discovery ─────────────────────────────────────────────── @@ -827,22 +821,21 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String { writeln!(out, " splines=ortho;").unwrap(); writeln!(out).unwrap(); - // Define module ordering for consistent output - let module_order = [ - "error", - "config", - "channel", - "actor", - "address_map", - "runtime", - "worker", - "python", - ]; + // Use actual module names in discovery order for consistent output + let module_order: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect(); + + // Build module_name → index lookup for palette rotation + let module_index: HashMap<&str, usize> = module_order + .iter() + .enumerate() + .map(|(i, &name)| (name, i)) + .collect(); // 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) { - 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(); 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 @@ -928,7 +922,8 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String { ) .unwrap(); 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 } -fn emit_cluster(out: &mut String, module: &ModuleInfo) { - let (cluster_fill, cluster_border, node_fill) = module_colors(&module.name); +fn emit_cluster(out: &mut String, module: &ModuleInfo, cluster_fill: &str, cluster_border: &str, node_fill: &str) { let style = if module.feature_gate.is_some() { "rounded,dashed,filled" @@ -1010,12 +1004,7 @@ fn emit_cluster(out: &mut String, module: &ModuleInfo) { writeln!(out, " }}").unwrap(); } -fn emit_edge(out: &mut String, edge: &Edge, intra: bool) { - let color = if intra { - "#666" - } else { - module_edge_color(&edge.from_module) - }; +fn emit_edge(out: &mut String, edge: &Edge, intra: bool, color: &str) { let (style, penwidth) = match edge.kind { EdgeKind::TraitImpl => { @@ -1247,6 +1236,7 @@ fn main() { let mut src_dir = PathBuf::from("src"); let mut output_prefix = String::from("deps"); + let mut output_dir: Option = None; let mut i = 1; while i < args.len() { @@ -1259,11 +1249,16 @@ fn main() { i += 1; output_prefix = args[i].clone(); } + "--output-dir" => { + i += 1; + output_dir = Some(PathBuf::from(&args[i])); + } "--help" | "-h" => { - eprintln!("Usage: depgraph [--src-dir src/] [--output deps]"); - eprintln!(" --src-dir DIR Source directory (default: src/)"); - eprintln!(" --output PREFIX Output prefix (default: deps)"); - eprintln!(" Produces PREFIX.dot and PREFIX.html"); + eprintln!("Usage: depgraph [--src-dir src/] [--output deps] [--output-dir DIR]"); + eprintln!(" --src-dir DIR Source directory (default: src/)"); + eprintln!(" --output PREFIX Output prefix (default: deps)"); + eprintln!(" --output-dir DIR Directory for output files (default: cwd)"); + eprintln!(" Produces PREFIX.dot and PREFIX.html"); std::process::exit(0); } other => { @@ -1274,6 +1269,11 @@ fn main() { 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()); // Phase 1: Module discovery @@ -1330,15 +1330,23 @@ fn main() { // Phase 5: DOT output 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"); - eprintln!("Wrote {}", dot_path); + eprintln!("Wrote {}", dot_path.display()); // Phase 6: HTML output 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"); - eprintln!("Wrote {}", html_path); + eprintln!("Wrote {}", html_path.display()); eprintln!("Done!"); } diff --git a/tools/spectral/spectral_analysis.py b/tools/spectral/spectral_analysis.py index 57f2a44..bb9948b 100644 --- a/tools/spectral/spectral_analysis.py +++ b/tools/spectral/spectral_analysis.py @@ -87,6 +87,20 @@ class ComplexityMetrics: 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 ─────────────────────────────────────────────────────────────── 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 ──────────────────────────────────────────────────────────── @dataclass @@ -411,6 +536,7 @@ class AnalysisResult: spectral: SpectralResults coupling: ModuleCouplingResult metrics: ComplexityMetrics + structural: StructuralProperties def run_analysis(graph: DependencyGraph) -> AnalysisResult: @@ -418,11 +544,13 @@ def run_analysis(graph: DependencyGraph) -> AnalysisResult: spectral = compute_spectral(graph) coupling = compute_module_coupling(graph) metrics = compute_complexity_metrics(spectral, coupling) + structural = compute_structural_properties(graph, spectral) return AnalysisResult( graph=graph, spectral=spectral, coupling=coupling, metrics=metrics, + structural=structural, ) @@ -433,6 +561,7 @@ def generate_report(result: AnalysisResult) -> str: s = result.spectral m = result.metrics c = result.coupling + p = result.structural lines: list[str] = [] def w(text: str = "") -> None: @@ -453,37 +582,29 @@ def generate_report(result: AnalysisResult) -> str: w(f" Modules: {', '.join(c.module_names)}") w() - # Eigenvalue spectrum - w("LAPLACIAN EIGENVALUE SPECTRUM") + # Structural properties + w("STRUCTURAL PROPERTIES") w("-" * 40) - for i, ev in enumerate(s.eigenvalues): - marker = " <-- Fiedler value (lambda_2)" if i == 1 else "" - w(f" lambda_{i:2d} = {ev:8.4f}{marker}") - w() - if len(s.eigenvalues) > 1: - 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(f" Edges/node (avg degree): {p.avg_degree:.2f}") + w(f" Max fan-in: {p.max_fan_in:<4d} ({p.max_fan_in_node})") + w(f" Max fan-out: {p.max_fan_out:<4d} ({p.max_fan_out_node})") + w(f" DAG depth: {p.dag_depth}") + w(f" Clustering coefficient: {p.clustering_coeff:.4f}") w() - # Fiedler vector analysis - if len(s.fiedler_vector) > 0: - w("FIEDLER VECTOR — SPECTRAL BISECTION") - w("-" * 40) - # Sort by fiedler value - indices = np.argsort(s.fiedler_vector) - w(" Partition A (Fiedler < 0):") - 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(" Partition B (Fiedler >= 0):") - 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() + # Module cohesion + w("MODULE COHESION") + w("-" * 40) + w(f" {'Module':<16s} {'Size':>5s} {'Cohesion':>8s}") + for mod in c.module_names: + coh = p.module_cohesion.get(mod, float("nan")) + size = sum(1 for n in result.graph.nodes if n.module == mod) + coh_str = f"{coh:.3f}" if not math.isnan(coh) else " —" + w(f" {mod:<16s} {size:>5d} {coh_str:>8s}") + w(f" {'─' * 32}") + w(f" {'Average cohesion:':<22s} {p.avg_module_cohesion:8.3f}") + w(f" {'Avg module size:':<22s} {p.avg_module_size:8.1f}") + w() # Module coupling w("MODULE COUPLING MATRIX (directed edge counts)") @@ -545,23 +666,28 @@ def generate_report(result: AnalysisResult) -> str: # ─── Dashboard Visualization ───────────────────────────────────────────────── -# Module colors matching the depgraph tool -MODULE_COLORS = { - "error": "#4caf50", - "config": "#8bc34a", - "channel": "#ffeb3b", - "actor": "#2196f3", - "address_map": "#9c27b0", - "runtime": "#f44336", - "worker": "#ff9800", - "python": "#795548", -} +# Module border colors from the depgraph palette (used as the accent color). +# These rotate by discovery-order index; the palette has 8 entries. +_PALETTE_BORDER = [ + "#1565c0", # 0 — blue + "#c62828", # 1 — red + "#e65100", # 2 — orange + "#7b1fa2", # 3 — purple + "#2e7d32", # 4 — green + "#f9a825", # 5 — yellow + "#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: - 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: @@ -571,6 +697,11 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None: import matplotlib.pyplot as plt 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 m = result.metrics c = result.coupling @@ -595,63 +726,52 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None: fig.suptitle("Spectral Analysis Dashboard — Dependency DAG", fontsize=16, fontweight="bold", color="#e0e0e0") - # ── Top-left: Eigenvalue spectrum ── + p = result.structural + + # ── Top-left: Structural properties ── ax1 = fig.add_subplot(gs[0, 0]) - n = len(s.eigenvalues) - colors_eig = ["#ff4444" if i == 1 else "#4fc3f7" for i in range(n)] - markerline, stemlines, baseline = ax1.stem( - range(n), s.eigenvalues, linefmt="-", markerfmt="o", basefmt=" " - ) - markerline.set_color("#4fc3f7") - markerline.set_markersize(5) - stemlines.set_color("#4fc3f7") - stemlines.set_alpha(0.6) - # Highlight lambda_2 - if n > 1: - ax1.plot(1, s.eigenvalues[1], "o", color="#ff4444", markersize=10, - zorder=5, label=f"$\\lambda_2$ = {s.fiedler_value:.4f}") - ax1.legend(fontsize=10, loc="upper left", - facecolor="#16213e", edgecolor="#444") - ax1.set_xlabel("Index") - ax1.set_ylabel("Eigenvalue") - ax1.set_title("Laplacian Eigenvalue Spectrum", fontsize=12, fontweight="bold") - ax1.grid(True, alpha=0.3) + ax1.axis("off") + ax1.set_title("Structural Properties", fontsize=12, fontweight="bold") + props = [ + ("Edges/node (avg degree)", f"{p.avg_degree:.2f}"), + ("Max fan-in", f"{p.max_fan_in} ({p.max_fan_in_node})"), + ("Max fan-out", f"{p.max_fan_out} ({p.max_fan_out_node})"), + ("DAG depth", f"{p.dag_depth}"), + ("Clustering coefficient", f"{p.clustering_coeff:.4f}"), + ("Avg module size", f"{p.avg_module_size:.1f}"), + ("Avg module cohesion", f"{p.avg_module_cohesion:.3f}"), + ] + y = 0.88 + for label, value in props: + ax1.text(0.05, y, label, transform=ax1.transAxes, fontsize=10, + color="#aaa", fontfamily="monospace", va="top") + ax1.text(0.95, y, value, transform=ax1.transAxes, fontsize=10, + fontweight="bold", color="#e0e0e0", fontfamily="monospace", + va="top", ha="right") + y -= 0.12 - # ── Top-right: Fiedler vector ── + # ── Top-right: Module cohesion ── ax2 = fig.add_subplot(gs[0, 1]) - if len(s.fiedler_vector) > 0: - sorted_indices = np.argsort(s.fiedler_vector) - sorted_values = s.fiedler_vector[sorted_indices] - sorted_names = [s.node_names[i] for i in sorted_indices] - sorted_modules = [s.node_modules[i] for i in sorted_indices] - bar_colors = [get_module_color(mod) for mod in sorted_modules] - - bars = ax2.barh(range(len(sorted_values)), sorted_values, - color=bar_colors, edgecolor="none", height=0.8) - ax2.axvline(x=0, color="#ff4444", linewidth=1.5, linestyle="--", - alpha=0.8, label="Bisection boundary") - ax2.set_yticks(range(len(sorted_names))) - ax2.set_yticklabels(sorted_names, fontsize=6) - ax2.set_xlabel("Fiedler value") - ax2.set_title("Fiedler Vector (spectral bisection)", fontsize=12, - fontweight="bold") - - # 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) + cohesion_mods = [mod for mod in c.module_names + if not math.isnan(p.module_cohesion.get(mod, float("nan")))] + if cohesion_mods: + cohesion_vals = [p.module_cohesion[mod] for mod in cohesion_mods] + bar_colors = [get_module_color(mod) for mod in cohesion_mods] + bars = ax2.barh(range(len(cohesion_mods)), cohesion_vals, + color=bar_colors, edgecolor="none", height=0.6) + ax2.set_yticks(range(len(cohesion_mods))) + ax2.set_yticklabels(cohesion_mods, fontsize=9) + ax2.set_xlim(0, 1.05) + ax2.set_xlabel("Cohesion (intra-edges / max possible)") + ax2.axvline(x=p.avg_module_cohesion, color="#ff4444", linewidth=1.5, + linestyle="--", alpha=0.7, label=f"avg = {p.avg_module_cohesion:.3f}") + ax2.legend(fontsize=9, loc="lower right", + facecolor="#16213e", edgecolor="#444") + ax2.grid(True, axis="x", alpha=0.3) 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) - ax2.set_title("Fiedler Vector", fontsize=12, fontweight="bold") + ax2.set_title("Module Cohesion", fontsize=12, fontweight="bold") # ── Bottom-left: Module coupling heatmap ── ax3 = fig.add_subplot(gs[1, 0]) @@ -746,25 +866,40 @@ def generate_dashboard_html( m = result.metrics c = result.coupling - # Prepare data as JSON for embedding - 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]), - }) + p = result.structural - eigenvalue_data = [{"index": i, "value": float(v)} - for i, v in enumerate(s.eigenvalues)] + # Prepare data as JSON for embedding + 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 = { "modules": c.module_names, "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)) 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_color": cci_color, "cci_desc": cci_desc, - "fiedler_value": round(s.fiedler_value, 4), } data_blob = json.dumps({ - "eigenvalues": eigenvalue_data, - "fiedler": fiedler_data, + "structural": structural_data, + "cohesion": cohesion_data, "coupling": coupling_data, "metrics": metrics_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:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; } -/* Eigenvalue bars */ -.ev-bar { cursor:pointer; transition:opacity 0.15s; } -.ev-bar:hover { opacity:0.8; } - -/* Fiedler bars */ +/* Cohesion / heatmap bars */ .fi-bar { cursor:pointer; transition:opacity 0.15s; } .fi-bar:hover { opacity:0.85; } @@ -914,11 +1044,11 @@ svg text { user-select:none; }
swactor — dependency analysis
- - + +
-
+
@@ -929,16 +1059,16 @@ svg text { user-select:none; }
Loading Graphviz…
-
+
-
-

λ Laplacian Eigenvalue Spectrum

- +
+

◉ Structural Properties

+
-
-

✂ Fiedler Vector — Spectral Bisection

- +
+

▨ Module Cohesion

+
@@ -959,7 +1089,7 @@ svg text { user-select:none; }