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
Generated
+9
@@ -758,6 +758,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cm-topology"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/cm-domain",
|
"crates/cm-domain",
|
||||||
|
"crates/cm-topology",
|
||||||
"crates/cm-config",
|
"crates/cm-config",
|
||||||
"crates/cm-db",
|
"crates/cm-db",
|
||||||
"crates/cm-llm",
|
"crates/cm-llm",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "cm-topology"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Dynamic Agentic Topologies — platform architecture
|
||||||
|
|
||||||
|
Status: **Phase 0/1 (foundation)**. Source of truth for the topology-switching capability.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Let users build agentic systems that **swap into any organizational topology**, run a task across
|
||||||
|
topologies on ClawMates' real **§15-safe per-tenant runtime**, and **measure** which patterns yield
|
||||||
|
better results — producing both a product capability and reproducible data for a paper on
|
||||||
|
*large dynamic agentic topologies*.
|
||||||
|
|
||||||
|
We re-implement the topology IP from `~/projects/agentorg` in **pure Rust** (agentorg's modeling is
|
||||||
|
real; its execution is largely simulated — ClawMates supplies the missing safe execution).
|
||||||
|
|
||||||
|
## Curated topology set (v1)
|
||||||
|
Start with topologies that **differ meaningfully in execution** (governance/novel forms deferred):
|
||||||
|
|
||||||
|
| Kind | Shape | Execution pattern |
|
||||||
|
|---|---|---|
|
||||||
|
| `hierarchical` | tree | orchestrator delegates down; results bubble up |
|
||||||
|
| `flat` | peers, light coord | autonomous peers, minimal coordination |
|
||||||
|
| `pipeline` | linear stages | output of stage *n* → input of stage *n+1* |
|
||||||
|
| `swarm` | broadcast + vote | parallel attempts, consensus/aggregation |
|
||||||
|
| `mesh` | dense peer-to-peer | peers exchange freely until convergence |
|
||||||
|
| `hub_spoke` | central hub | hub routes to spokes, aggregates |
|
||||||
|
| `ring` | cycle | sequential round-trips, refine each lap |
|
||||||
|
| `star_moe` | router + experts | router picks expert(s) per subtask (MoE) |
|
||||||
|
| `market` | auction | tasks bid out to agents by fit/cost |
|
||||||
|
| `blackboard` | shared memory | agents read/write a shared workspace |
|
||||||
|
| `debate` | adversarial | proposer vs critic rounds → judge |
|
||||||
|
| `holacratic` | self-org circles | circles self-assign within roles |
|
||||||
|
|
||||||
|
Each kind carries **heuristics** (default role distribution + optimization weights), ported from
|
||||||
|
agentorg `agents/swarmagentic/swarm/topology_manager.py`.
|
||||||
|
|
||||||
|
## `TopologyGraph` schema (`cm-topology`)
|
||||||
|
- **Node** = a role slot: `{ id, role, level?, attrs }` (bound to a real claw/`AgentId` at run time).
|
||||||
|
- **Edge** = a relationship: `{ from, to, kind }` where `EdgeKind ∈ {delegates_to, reports_to,
|
||||||
|
pipes_to, peers_with, routes_to, bids_to, reads_writes}`.
|
||||||
|
- A graph also records its declared `TopologyKind`. The **classifier** can also *infer* the kind from
|
||||||
|
graph metrics (degree/centrality variance, density, clustering, modularity, hierarchy score,
|
||||||
|
diameter) → `{ kind, confidence }`, ported from `topology_classifier.py`.
|
||||||
|
- **Adapter:** a serde `TopologySpec` (JSON/YAML) normalizes into a `TopologyGraph` (mirrors
|
||||||
|
`org_adapter/normalize_topology.py`).
|
||||||
|
|
||||||
|
## §15 edge contract (non-negotiable)
|
||||||
|
A topology only describes *who talks to whom and who decides*. **Execution semantics never bypass
|
||||||
|
ClawMates safety:** any edge whose action leaves the sandbox (email/Slack/pay/delete/etc.) routes
|
||||||
|
through `cm-safety` (approvals + single-use secret broker + audit journal), exactly as a single claw
|
||||||
|
does today. Switching topology cannot widen an agent's authority. This is the platform's moat and the
|
||||||
|
paper's safety contribution.
|
||||||
|
|
||||||
|
## Crate / component map
|
||||||
|
- **`crates/cm-topology`** (this phase): pure, offline — kinds, graph, adapter, classifier, heuristics. Tests only.
|
||||||
|
- **`crates/cm-orchestrator`** (Phase 2): binds a `TopologyGraph` to real claws and executes a task per
|
||||||
|
pattern, gating every sandbox-leaving edge via `cm-safety`; journals steps to `run_events`.
|
||||||
|
- **Experiment harness** (Phase 4, in orchestrator): run a task across N topologies; capture
|
||||||
|
cost/quality(`cm-llm` judge)/latency/#approvals/#steps; compute Pareto + leaderboard. Seeded + pinned model.
|
||||||
|
- **`cm-api`**: CRUD topologies; create runs (single / workflow-of-topologies / comparison); fetch results.
|
||||||
|
- **`cm-db`**: `topologies`, `topology_runs`, `run_results`, `pareto_snapshots`.
|
||||||
|
- **`frontend/`**: ReactFlow topology builder + Pareto/leaderboard view.
|
||||||
|
|
||||||
|
## agentorg → Rust port map
|
||||||
|
| agentorg (Python) | ClawMates (Rust) |
|
||||||
|
|---|---|
|
||||||
|
| `agents/topology_classifier.py` (`TopologyType`, classifier) | `cm-topology::kind`, `cm-topology::classifier` |
|
||||||
|
| `org_adapter/normalize_topology.py` (`OrganizationGraph`, adapter) | `cm-topology::graph`, `cm-topology::adapter` |
|
||||||
|
| `agents/swarmagentic/swarm/topology_manager.py` (heuristics) | `cm-topology::heuristics` |
|
||||||
|
| `swarm_templates/` (templates) | seed catalog under `cm-topology` (later) |
|
||||||
|
| `benchmarks/`, `monitoring/pareto/` | experiment harness (Phase 4) |
|
||||||
|
| `papers/*` | `papers/dynamic-agentic-topologies.md` |
|
||||||
|
|
||||||
|
## Phases & verification
|
||||||
|
- **P1** `cm-topology`: `cargo test -p cm-topology` — known graphs classify to expected kinds; spec
|
||||||
|
round-trips; every kind has heuristics.
|
||||||
|
- **P2** runtime: one task runs under a topology on a dev tenant; every gated action still needs approval.
|
||||||
|
- **P3/P4**: user selects a topology/workflow and runs it; same task across ≥3 topologies → real
|
||||||
|
cost/quality leaderboard + Pareto; existing e2e/§15 suites stay green.
|
||||||
|
- **Paper**: one command reproduces a benchmark table from logged runs.
|
||||||
Reference in New Issue
Block a user