feat(topology): cm-topology crate + architecture doc (Phases 0–1)
Foundation for the dynamic agentic-topologies platform (see docs/topology-platform.md), porting agentorg's topology modeling into pure Rust: - TopologyKind: curated 12-kind taxonomy (hierarchical, flat, pipeline, swarm, mesh, hub_spoke, ring, star_moe, market, blackboard, debate, holacratic). - TopologyGraph: role-slot nodes + typed edges, with validation. - adapter: normalize a loose JSON spec → validated graph (fills edge kinds). - classifier: structural metrics (density, hub dominance, clustering, diameter, hierarchy score) → inferred kind + confidence (tree→hierarchical, line→pipeline, cycle→ring, star→hub_spoke, complete→mesh, empty→flat). - heuristics: per-kind role distributions (ported from topology_manager.py). Pure, offline, dependency-light (serde/thiserror). 17 unit tests, clippy clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
34da54ccaa
commit
817d8c712c
@@ -0,0 +1,305 @@
|
||||
//! 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 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)]
|
||||
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)]
|
||||
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<TopologyKind>,
|
||||
/// 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<usize> = adj.iter().map(|s| s.len()).collect();
|
||||
let undirected_edges: usize = degrees.iter().sum::<usize>() / 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<usize> = 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, 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<usize>], 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<usize> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user