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,115 @@
|
||||
//! Normalize a loose JSON spec into a validated [`TopologyGraph`].
|
||||
//!
|
||||
//! Mirrors agentorg's `org_adapter` normalization: callers may omit each
|
||||
//! edge's `kind`, and it is filled with the sensible default for the declared
|
||||
//! topology. (YAML/CSV inputs can be added later behind feature flags.)
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::graph::{Edge, EdgeKind, Node, TopologyGraph};
|
||||
use crate::kind::TopologyKind;
|
||||
use crate::TopologyError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EdgeSpec {
|
||||
from: String,
|
||||
to: String,
|
||||
#[serde(default)]
|
||||
kind: Option<EdgeKind>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphSpec {
|
||||
kind: TopologyKind,
|
||||
nodes: Vec<Node>,
|
||||
#[serde(default)]
|
||||
edges: Vec<EdgeSpec>,
|
||||
}
|
||||
|
||||
/// The natural edge semantic for a topology, used when a spec omits it.
|
||||
fn default_edge_kind(t: TopologyKind) -> EdgeKind {
|
||||
use TopologyKind::*;
|
||||
match t {
|
||||
Hierarchical => EdgeKind::DelegatesTo,
|
||||
Flat | Mesh | Holacratic | Debate => EdgeKind::PeersWith,
|
||||
Pipeline | Ring => EdgeKind::PipesTo,
|
||||
Swarm | HubSpoke | StarMoe => EdgeKind::RoutesTo,
|
||||
Market => EdgeKind::BidsTo,
|
||||
Blackboard => EdgeKind::ReadsWrites,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and normalize a JSON topology spec into a validated graph.
|
||||
pub fn from_json(s: &str) -> Result<TopologyGraph, TopologyError> {
|
||||
let spec: GraphSpec =
|
||||
serde_json::from_str(s).map_err(|e| TopologyError::Spec(e.to_string()))?;
|
||||
let default = default_edge_kind(spec.kind);
|
||||
let edges = spec
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|e| Edge {
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
kind: e.kind.unwrap_or(default),
|
||||
})
|
||||
.collect();
|
||||
TopologyGraph::new(spec.kind, spec.nodes, edges)
|
||||
}
|
||||
|
||||
/// Serialize a graph back to pretty JSON.
|
||||
pub fn to_json(g: &TopologyGraph) -> Result<String, TopologyError> {
|
||||
serde_json::to_string_pretty(g).map_err(|e| TopologyError::Spec(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fills_default_edge_kind() {
|
||||
let g = from_json(
|
||||
r#"{
|
||||
"kind": "pipeline",
|
||||
"nodes": [{"id":"a","role":"x"},{"id":"b","role":"y"}],
|
||||
"edges": [{"from":"a","to":"b"}]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(g.edges[0].kind, EdgeKind::PipesTo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_edge_kind_wins() {
|
||||
let g = from_json(
|
||||
r#"{
|
||||
"kind": "hierarchical",
|
||||
"nodes": [{"id":"a","role":"x"},{"id":"b","role":"y"}],
|
||||
"edges": [{"from":"a","to":"b","kind":"reports_to"}]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(g.edges[0].kind, EdgeKind::ReportsTo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_json() {
|
||||
let g = TopologyGraph::new(
|
||||
TopologyKind::HubSpoke,
|
||||
vec![Node::new("hub", "router"), Node::new("s1", "worker")],
|
||||
vec![Edge {
|
||||
from: "hub".into(),
|
||||
to: "s1".into(),
|
||||
kind: EdgeKind::RoutesTo,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let json = to_json(&g).unwrap();
|
||||
let back = from_json(&json).unwrap();
|
||||
assert_eq!(g, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_spec_is_an_error() {
|
||||
assert!(matches!(from_json("not json"), Err(TopologyError::Spec(_))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Per-topology heuristics: a default role mix + a short rationale.
|
||||
//!
|
||||
//! Ported/adapted from agentorg's `topology_manager.py` role distributions.
|
||||
//! These guide how the orchestrator (Phase 2) staffs a topology and how the
|
||||
//! evolution layer (Phase 5) biases search; they are advisory, not hard rules.
|
||||
|
||||
use crate::kind::TopologyKind;
|
||||
|
||||
/// Advisory staffing/optimization hints for a topology.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Heuristics {
|
||||
/// Suggested fraction of agents per role (sums to ~1.0).
|
||||
pub role_distribution: &'static [(&'static str, f32)],
|
||||
/// Why this mix suits the topology.
|
||||
pub notes: &'static str,
|
||||
}
|
||||
|
||||
/// Heuristics for a given topology kind. Every kind is covered.
|
||||
pub fn heuristics(kind: TopologyKind) -> Heuristics {
|
||||
use TopologyKind::*;
|
||||
match kind {
|
||||
Hierarchical => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.15),
|
||||
("executor", 0.60),
|
||||
("analyst", 0.15),
|
||||
("innovator", 0.05),
|
||||
("validator", 0.05),
|
||||
],
|
||||
notes: "few coordinators, many executors; optimize span of control and depth",
|
||||
},
|
||||
Flat => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.05),
|
||||
("executor", 0.30),
|
||||
("analyst", 0.25),
|
||||
("innovator", 0.25),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "high autonomy; reward peer collaboration, penalize consensus drag",
|
||||
},
|
||||
Pipeline => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.55),
|
||||
("analyst", 0.15),
|
||||
("innovator", 0.05),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "stage specialists in sequence; validators between stages",
|
||||
},
|
||||
Swarm => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.25),
|
||||
("analyst", 0.25),
|
||||
("innovator", 0.25),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "value diversity, reward emergent behavior, penalize redundancy",
|
||||
},
|
||||
Mesh => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.05),
|
||||
("executor", 0.30),
|
||||
("analyst", 0.30),
|
||||
("innovator", 0.20),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "dense peer exchange; analysts dominate to converge information",
|
||||
},
|
||||
HubSpoke => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.30),
|
||||
("executor", 0.50),
|
||||
("analyst", 0.10),
|
||||
("innovator", 0.05),
|
||||
("validator", 0.05),
|
||||
],
|
||||
notes: "central hub coordinates; cap hub fan-out, reward routing efficiency",
|
||||
},
|
||||
Ring => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.40),
|
||||
("analyst", 0.20),
|
||||
("innovator", 0.15),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "sequential cycles; reward cycle completion and bidirectional flow",
|
||||
},
|
||||
StarMoe => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.15),
|
||||
("executor", 0.55),
|
||||
("analyst", 0.20),
|
||||
("innovator", 0.05),
|
||||
("validator", 0.05),
|
||||
],
|
||||
notes: "a router dispatches to specialist experts; reward routing accuracy",
|
||||
},
|
||||
Market => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.55),
|
||||
("analyst", 0.20),
|
||||
("innovator", 0.05),
|
||||
("validator", 0.10),
|
||||
],
|
||||
notes: "agents bid on tasks by fit/cost; reward allocative efficiency",
|
||||
},
|
||||
Blackboard => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.35),
|
||||
("analyst", 0.30),
|
||||
("innovator", 0.15),
|
||||
("validator", 0.10),
|
||||
],
|
||||
notes: "shared workspace; analysts synthesize contributions opportunistically",
|
||||
},
|
||||
Debate => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.10),
|
||||
("executor", 0.35),
|
||||
("analyst", 0.20),
|
||||
("innovator", 0.20),
|
||||
("validator", 0.15),
|
||||
],
|
||||
notes: "proposer vs critic with a judge; reward decisive, well-argued outcomes",
|
||||
},
|
||||
Holacratic => Heuristics {
|
||||
role_distribution: &[
|
||||
("coordinator", 0.20),
|
||||
("executor", 0.30),
|
||||
("analyst", 0.20),
|
||||
("innovator", 0.20),
|
||||
("validator", 0.10),
|
||||
],
|
||||
notes: "self-organizing circles; reward clear roles and tension resolution",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kind_has_valid_distribution() {
|
||||
for k in TopologyKind::ALL {
|
||||
let h = heuristics(k);
|
||||
assert!(!h.role_distribution.is_empty(), "{} empty", k.as_str());
|
||||
assert!(!h.notes.is_empty());
|
||||
let sum: f32 = h.role_distribution.iter().map(|(_, w)| *w).sum();
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 0.01,
|
||||
"{} role distribution sums to {sum}, expected ~1.0",
|
||||
k.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! The curated taxonomy of execution-meaningful topologies (v1).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An organizational topology pattern. The v1 set covers shapes that differ
|
||||
/// *in how a task executes*; governance/novel forms are deferred.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TopologyKind {
|
||||
/// Tree: an orchestrator delegates down; results bubble up.
|
||||
Hierarchical,
|
||||
/// Autonomous peers with minimal coordination.
|
||||
Flat,
|
||||
/// Linear stages: output of stage n feeds stage n+1.
|
||||
Pipeline,
|
||||
/// Parallel attempts with consensus/aggregation.
|
||||
Swarm,
|
||||
/// Dense peer-to-peer exchange until convergence.
|
||||
Mesh,
|
||||
/// A central hub routes to spokes and aggregates.
|
||||
HubSpoke,
|
||||
/// A cycle: sequential round-trips, refining each lap.
|
||||
Ring,
|
||||
/// Router + experts (mixture-of-experts).
|
||||
StarMoe,
|
||||
/// Tasks auctioned to agents by fit/cost.
|
||||
Market,
|
||||
/// Agents read/write a shared workspace.
|
||||
Blackboard,
|
||||
/// Adversarial proposer vs critic rounds, then a judge.
|
||||
Debate,
|
||||
/// Self-organizing circles assign within roles.
|
||||
Holacratic,
|
||||
}
|
||||
|
||||
impl TopologyKind {
|
||||
/// Every supported kind, for iteration in tests/UIs/benchmarks.
|
||||
pub const ALL: [TopologyKind; 12] = [
|
||||
TopologyKind::Hierarchical,
|
||||
TopologyKind::Flat,
|
||||
TopologyKind::Pipeline,
|
||||
TopologyKind::Swarm,
|
||||
TopologyKind::Mesh,
|
||||
TopologyKind::HubSpoke,
|
||||
TopologyKind::Ring,
|
||||
TopologyKind::StarMoe,
|
||||
TopologyKind::Market,
|
||||
TopologyKind::Blackboard,
|
||||
TopologyKind::Debate,
|
||||
TopologyKind::Holacratic,
|
||||
];
|
||||
|
||||
/// The snake_case wire name (matches the serde representation).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TopologyKind::Hierarchical => "hierarchical",
|
||||
TopologyKind::Flat => "flat",
|
||||
TopologyKind::Pipeline => "pipeline",
|
||||
TopologyKind::Swarm => "swarm",
|
||||
TopologyKind::Mesh => "mesh",
|
||||
TopologyKind::HubSpoke => "hub_spoke",
|
||||
TopologyKind::Ring => "ring",
|
||||
TopologyKind::StarMoe => "star_moe",
|
||||
TopologyKind::Market => "market",
|
||||
TopologyKind::Blackboard => "blackboard",
|
||||
TopologyKind::Debate => "debate",
|
||||
TopologyKind::Holacratic => "holacratic",
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line human description.
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
TopologyKind::Hierarchical => "orchestrator delegates down; results bubble up",
|
||||
TopologyKind::Flat => "autonomous peers with minimal coordination",
|
||||
TopologyKind::Pipeline => "linear stages; each feeds the next",
|
||||
TopologyKind::Swarm => "parallel attempts with consensus/aggregation",
|
||||
TopologyKind::Mesh => "dense peer-to-peer exchange until convergence",
|
||||
TopologyKind::HubSpoke => "a central hub routes to spokes and aggregates",
|
||||
TopologyKind::Ring => "a cycle refining the result each lap",
|
||||
TopologyKind::StarMoe => "a router dispatches subtasks to experts",
|
||||
TopologyKind::Market => "tasks auctioned to agents by fit/cost",
|
||||
TopologyKind::Blackboard => "agents collaborate via a shared workspace",
|
||||
TopologyKind::Debate => "proposer vs critic rounds, then a judge",
|
||||
TopologyKind::Holacratic => "self-organizing circles assign within roles",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn as_str_matches_serde() {
|
||||
for k in TopologyKind::ALL {
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", k.as_str()));
|
||||
let back: TopologyKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, k);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_is_complete_and_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for k in TopologyKind::ALL {
|
||||
assert!(seen.insert(k.as_str()), "duplicate in ALL: {}", k.as_str());
|
||||
assert!(!k.description().is_empty());
|
||||
}
|
||||
assert_eq!(seen.len(), 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Organizational topologies for agentic systems — pure, offline modeling.
|
||||
//!
|
||||
//! A [`TopologyGraph`] describes *who talks to whom and who decides* in a
|
||||
//! system of agents (claws). It is a description only: execution semantics
|
||||
//! (and the §15 safety gates) live in the orchestrator, never here.
|
||||
//!
|
||||
//! This crate is dependency-light (serde + thiserror) and has no I/O, so it
|
||||
//! doubles as a reusable library for benchmarking/topology research.
|
||||
//!
|
||||
//! Modules:
|
||||
//! - [`kind`] — the curated [`TopologyKind`] taxonomy.
|
||||
//! - [`graph`] — the [`TopologyGraph`] data model + validation.
|
||||
//! - [`adapter`] — normalize a loose JSON/YAML spec into a graph.
|
||||
//! - [`classifier`]— infer a topology kind from a graph's structure.
|
||||
//! - [`heuristics`]— per-kind role distributions / optimization weights.
|
||||
|
||||
mod adapter;
|
||||
mod classifier;
|
||||
mod graph;
|
||||
mod heuristics;
|
||||
mod kind;
|
||||
|
||||
pub use adapter::{from_json, to_json};
|
||||
pub use classifier::{classify, Classification, GraphMetrics};
|
||||
pub use graph::{Edge, EdgeKind, Node, TopologyGraph};
|
||||
pub use heuristics::{heuristics, Heuristics};
|
||||
pub use kind::TopologyKind;
|
||||
|
||||
/// Errors produced while building or validating a topology.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TopologyError {
|
||||
/// The graph has no nodes.
|
||||
#[error("topology has no nodes")]
|
||||
Empty,
|
||||
/// An edge references a node id that does not exist.
|
||||
#[error("edge references unknown node: {0}")]
|
||||
UnknownNode(String),
|
||||
/// Two nodes share an id.
|
||||
#[error("duplicate node id: {0}")]
|
||||
DuplicateNode(String),
|
||||
/// The input spec could not be parsed.
|
||||
#[error("invalid spec: {0}")]
|
||||
Spec(String),
|
||||
}
|
||||
Reference in New Issue
Block a user