//! Infer a [`TopologyKind`] from a graph's structure. //! //! Adapted from agentorg's `topology_classifier.py`: compute structural //! metrics (density, degree spread, hub dominance, clustering, hierarchy, //! diameter) and score each candidate kind. The declared `graph.kind` is //! authoritative; this is for *inference* (e.g. when importing an org) and for //! the benchmark/paper to characterize graphs objectively. use std::collections::VecDeque; use serde::Serialize; use crate::graph::TopologyGraph; use crate::kind::TopologyKind; /// Objective structural metrics for a topology graph (treated undirected for /// connectivity, directed for hierarchy). #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub struct GraphMetrics { /// Number of nodes. pub order: usize, /// Number of distinct undirected edges (self-loops/dupes removed). pub undirected_edges: usize, /// Edge density in `[0,1]` (undirected). pub density: f64, /// Mean undirected degree. pub avg_degree: f64, /// Largest undirected degree. pub max_degree: usize, /// `max_degree / sum_of_degrees` in `[0,1]` — how much one hub dominates. pub hub_dominance: f64, /// Average local clustering coefficient in `[0,1]`. pub clustering: f64, /// Number of connected components. pub components: usize, /// Longest shortest-path within the largest component. pub diameter: usize, /// Tree-likeness from directed in-degrees in `[0,1]` (1 = clean tree). pub hierarchy_score: f64, } /// The result of [`classify`]. #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub struct Classification { /// Best-matching topology kind. pub primary: TopologyKind, /// Score of the primary match in `[0,1]`. pub confidence: f64, /// Second-best kind, if any. pub runner_up: Option, /// The metrics the decision was based on. pub metrics: GraphMetrics, } /// Compute structural metrics for a graph. pub fn metrics(g: &TopologyGraph) -> GraphMetrics { let n = g.order(); let (_index, adj) = g.undirected_adjacency(); let degrees: Vec = adj.iter().map(|s| s.len()).collect(); let undirected_edges: usize = degrees.iter().sum::() / 2; let sum_deg: usize = degrees.iter().sum(); let max_degree = degrees.iter().copied().max().unwrap_or(0); let density = if n < 2 { 0.0 } else { undirected_edges as f64 / (n as f64 * (n as f64 - 1.0) / 2.0) }; let avg_degree = if n == 0 { 0.0 } else { sum_deg as f64 / n as f64 }; let hub_dominance = if sum_deg == 0 { 0.0 } else { max_degree as f64 / sum_deg as f64 }; // Average local clustering coefficient. let mut clustering_sum = 0.0; for neigh in adj.iter() { let k = neigh.len(); if k < 2 { continue; } let mut links = 0usize; let nbrs: Vec = neigh.iter().copied().collect(); for a in 0..nbrs.len() { for b in (a + 1)..nbrs.len() { if adj[nbrs[a]].contains(&nbrs[b]) { links += 1; } } } let possible = k * (k - 1) / 2; clustering_sum += links as f64 / possible as f64; } let clustering = if n == 0 { 0.0 } else { clustering_sum / n as f64 }; let (components, diameter) = components_and_diameter(&adj); // Hierarchy: directed in-degrees → one root + every node ≤1 parent ⇒ tree. let indeg = g.in_degrees(); let roots = indeg.iter().filter(|&&d| d == 0).count(); let single_parent = indeg.iter().filter(|&&d| d <= 1).count(); let hierarchy_score = if n == 0 { 0.0 } else if roots == 1 && components == 1 { single_parent as f64 / n as f64 } else { 0.3 * (single_parent as f64 / n as f64) }; GraphMetrics { order: n, undirected_edges, density, avg_degree, max_degree, hub_dominance, clustering, components, diameter, hierarchy_score, } } fn components_and_diameter(adj: &[std::collections::HashSet]) -> (usize, usize) { let n = adj.len(); let mut seen = vec![false; n]; let mut components = 0usize; let mut diameter = 0usize; for start in 0..n { if seen[start] { continue; } components += 1; // Collect the component, then run BFS eccentricity from each member. let mut comp = Vec::new(); let mut queue = VecDeque::from([start]); seen[start] = true; while let Some(u) = queue.pop_front() { comp.push(u); for &v in &adj[u] { if !seen[v] { seen[v] = true; queue.push_back(v); } } } for &src in &comp { let ecc = bfs_eccentricity(adj, src); diameter = diameter.max(ecc); } } (components, diameter) } fn bfs_eccentricity(adj: &[std::collections::HashSet], src: usize) -> usize { let mut dist = vec![usize::MAX; adj.len()]; dist[src] = 0; let mut queue = VecDeque::from([src]); let mut max = 0usize; while let Some(u) = queue.pop_front() { for &v in &adj[u] { if dist[v] == usize::MAX { dist[v] = dist[u] + 1; max = max.max(dist[v]); queue.push_back(v); } } } max } /// Classify a graph into the best-matching topology kind, with a confidence /// and runner-up. Structurally-clear shapes (tree, path, cycle, star, dense) /// score high; the "soft" kinds (market/blackboard/debate/holacratic) are /// inferred only weakly and are best taken from the declared `graph.kind`. pub fn classify(g: &TopologyGraph) -> Classification { use TopologyKind::*; let m = metrics(g); let n = m.order; // Shape detectors (undirected). let (_idx, adj) = g.undirected_adjacency(); let degrees: Vec = adj.iter().map(|s| s.len()).collect(); let connected = m.components == 1; let deg1 = degrees.iter().filter(|&&d| d == 1).count(); let deg2 = degrees.iter().filter(|&&d| d == 2).count(); let is_tree = connected && m.undirected_edges + 1 == n && n >= 2; let is_path = is_tree && deg1 == 2 && deg2 == n.saturating_sub(2); let is_star = is_tree && n >= 3 && m.max_degree == n - 1; let is_cycle = connected && n >= 3 && degrees.iter().all(|&d| d == 2) && m.undirected_edges == n; let swarm_fit = (1.0 - (m.density - 0.45).abs() * 2.0).clamp(0.0, 1.0) * 0.7; let scores: [(TopologyKind, f64); 12] = [ (Mesh, m.density), ( Flat, if m.undirected_edges == 0 { 1.0 } else { (1.0 - m.density) * 0.4 }, ), (Pipeline, if is_path { 0.95 } else { 0.0 }), (Ring, if is_cycle { 0.95 } else { 0.0 }), (HubSpoke, if is_star { 0.90 } else { m.hub_dominance * 0.5 }), (StarMoe, if is_star { 0.60 } else { m.hub_dominance * 0.3 }), ( Hierarchical, if is_tree && !is_path && !is_star { 0.85 + 0.1 * m.hierarchy_score } else { m.hierarchy_score * 0.5 }, ), (Swarm, swarm_fit), (Blackboard, 0.20), (Market, 0.20), (Debate, 0.20), (Holacratic, 0.20), ]; let mut ranked = scores; ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); Classification { primary: ranked[0].0, confidence: ranked[0].1.clamp(0.0, 1.0), runner_up: Some(ranked[1].0), metrics: m, } } #[cfg(test)] mod tests { use super::*; use crate::graph::{Edge, EdgeKind, Node}; fn graph(kind: TopologyKind, ids: &[&str], edges: &[(&str, &str)]) -> TopologyGraph { TopologyGraph::new( kind, ids.iter().map(|i| Node::new(*i, "role")).collect(), edges .iter() .map(|(a, b)| Edge { from: (*a).into(), to: (*b).into(), kind: EdgeKind::PeersWith, }) .collect(), ) .unwrap() } #[test] fn balanced_tree_is_hierarchical() { let g = graph( TopologyKind::Hierarchical, &["r", "a", "b", "a1", "a2", "b1", "b2"], &[ ("r", "a"), ("r", "b"), ("a", "a1"), ("a", "a2"), ("b", "b1"), ("b", "b2"), ], ); assert_eq!(classify(&g).primary, TopologyKind::Hierarchical); } #[test] fn line_is_pipeline() { let g = graph( TopologyKind::Pipeline, &["a", "b", "c", "d"], &[("a", "b"), ("b", "c"), ("c", "d")], ); assert_eq!(classify(&g).primary, TopologyKind::Pipeline); } #[test] fn cycle_is_ring() { let g = graph( TopologyKind::Ring, &["a", "b", "c", "d"], &[("a", "b"), ("b", "c"), ("c", "d"), ("d", "a")], ); assert_eq!(classify(&g).primary, TopologyKind::Ring); } #[test] fn star_is_hub_spoke() { let g = graph( TopologyKind::HubSpoke, &["h", "s1", "s2", "s3", "s4"], &[("h", "s1"), ("h", "s2"), ("h", "s3"), ("h", "s4")], ); assert_eq!(classify(&g).primary, TopologyKind::HubSpoke); } #[test] fn complete_graph_is_mesh() { let ids = ["a", "b", "c", "d", "e"]; let mut edges = Vec::new(); for i in 0..ids.len() { for j in (i + 1)..ids.len() { edges.push((ids[i], ids[j])); } } let g = graph(TopologyKind::Mesh, &ids, &edges); let c = classify(&g); assert_eq!(c.primary, TopologyKind::Mesh); assert!(c.confidence > 0.9, "mesh confidence {}", c.confidence); } #[test] fn no_edges_is_flat() { let g = graph(TopologyKind::Flat, &["a", "b", "c"], &[]); assert_eq!(classify(&g).primary, TopologyKind::Flat); } #[test] fn metrics_are_sane_for_a_path() { let g = graph( TopologyKind::Pipeline, &["a", "b", "c"], &[("a", "b"), ("b", "c")], ); let m = metrics(&g); assert_eq!(m.order, 3); assert_eq!(m.undirected_edges, 2); assert_eq!(m.components, 1); assert_eq!(m.diameter, 2); } }