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,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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user