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
+205
View File
@@ -0,0 +1,205 @@
//! The [`TopologyGraph`] data model: role-slot nodes and typed edges.
use std::collections::{BTreeMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::kind::TopologyKind;
use crate::TopologyError;
/// A role slot in the topology. At run time a node is bound to a concrete
/// claw (`AgentId`); here it is purely structural.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Node {
/// Stable identifier, unique within the graph.
pub id: String,
/// The role this slot plays (e.g. "orchestrator", "researcher").
pub role: String,
/// Optional depth hint (0 = top) for layered topologies.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<u32>,
/// Free-form attributes (model, budget, persona…).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attrs: BTreeMap<String, String>,
}
impl Node {
/// Convenience constructor for a bare role slot.
pub fn new(id: impl Into<String>, role: impl Into<String>) -> Self {
Node {
id: id.into(),
role: role.into(),
level: None,
attrs: BTreeMap::new(),
}
}
}
/// The semantic of an edge — how the `from` node relates to `to`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
/// `from` delegates work down to `to`.
DelegatesTo,
/// `from` reports results up to `to`.
ReportsTo,
/// `from` pipes its output into `to` (pipeline stage).
PipesTo,
/// `from` and `to` are collaborating peers.
PeersWith,
/// `from` routes/dispatches to `to` (hub/router).
RoutesTo,
/// `from` bids work out to `to` (market).
BidsTo,
/// `from` and `to` share a workspace (blackboard).
ReadsWrites,
}
/// A directed relationship between two nodes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edge {
/// Source node id.
pub from: String,
/// Target node id.
pub to: String,
/// Relationship semantic.
pub kind: EdgeKind,
}
/// A full topology: a declared [`TopologyKind`] plus its node/edge graph.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyGraph {
/// The declared topology kind (the classifier can also infer one).
pub kind: TopologyKind,
/// Role-slot nodes.
pub nodes: Vec<Node>,
/// Directed, typed edges.
pub edges: Vec<Edge>,
}
impl TopologyGraph {
/// Build a graph and validate it in one step.
pub fn new(
kind: TopologyKind,
nodes: Vec<Node>,
edges: Vec<Edge>,
) -> Result<Self, TopologyError> {
let g = TopologyGraph { kind, nodes, edges };
g.validate()?;
Ok(g)
}
/// Ensure the graph is well-formed: non-empty, unique ids, edges resolve.
pub fn validate(&self) -> Result<(), TopologyError> {
if self.nodes.is_empty() {
return Err(TopologyError::Empty);
}
let mut ids = HashSet::with_capacity(self.nodes.len());
for n in &self.nodes {
if !ids.insert(n.id.as_str()) {
return Err(TopologyError::DuplicateNode(n.id.clone()));
}
}
for e in &self.edges {
if !ids.contains(e.from.as_str()) {
return Err(TopologyError::UnknownNode(e.from.clone()));
}
if !ids.contains(e.to.as_str()) {
return Err(TopologyError::UnknownNode(e.to.clone()));
}
}
Ok(())
}
/// Node count.
pub fn order(&self) -> usize {
self.nodes.len()
}
/// Edge count.
pub fn size(&self) -> usize {
self.edges.len()
}
/// Undirected adjacency as index sets (used by metrics). Self-loops and
/// duplicate edges are collapsed. Returns `(index_of, neighbors)`.
pub(crate) fn undirected_adjacency(&self) -> (BTreeMap<&str, usize>, Vec<HashSet<usize>>) {
let index_of: BTreeMap<&str, usize> = self
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut adj = vec![HashSet::new(); self.nodes.len()];
for e in &self.edges {
if let (Some(&a), Some(&b)) = (index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
{
if a != b {
adj[a].insert(b);
adj[b].insert(a);
}
}
}
(index_of, adj)
}
/// Directed in-degree per node index (used for hierarchy detection).
pub(crate) fn in_degrees(&self) -> Vec<usize> {
let index_of: BTreeMap<&str, usize> = self
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut indeg = vec![0usize; self.nodes.len()];
for e in &self.edges {
if let Some(&b) = index_of.get(e.to.as_str()) {
if index_of.get(e.from.as_str()).copied() != Some(b) {
indeg[b] += 1;
}
}
}
indeg
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_rejects_empty() {
let g = TopologyGraph {
kind: TopologyKind::Flat,
nodes: vec![],
edges: vec![],
};
assert_eq!(g.validate(), Err(TopologyError::Empty));
}
#[test]
fn validate_rejects_unknown_edge() {
let err = TopologyGraph::new(
TopologyKind::Pipeline,
vec![Node::new("a", "x")],
vec![Edge {
from: "a".into(),
to: "ghost".into(),
kind: EdgeKind::PipesTo,
}],
)
.unwrap_err();
assert_eq!(err, TopologyError::UnknownNode("ghost".into()));
}
#[test]
fn validate_rejects_duplicate_node() {
let err = TopologyGraph::new(
TopologyKind::Flat,
vec![Node::new("a", "x"), Node::new("a", "y")],
vec![],
)
.unwrap_err();
assert_eq!(err, TopologyError::DuplicateNode("a".into()));
}
}