feat(topology): cm-topology crate + architecture doc (Phases 0–1)
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

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:
Omar Sobh
2026-06-15 20:05:06 -07:00
co-authored by Claude Opus 4.8
parent 34da54ccaa
commit 817d8c712c
10 changed files with 1048 additions and 0 deletions
+305
View File
@@ -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);
}
}