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
+115
View File
@@ -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(_))));
}
}
+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);
}
}
+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()));
}
}
+163
View File
@@ -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()
);
}
}
}
+113
View File
@@ -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);
}
}
+44
View File
@@ -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),
}