//! 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, /// Free-form attributes (model, budget, persona…). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub attrs: BTreeMap, } impl Node { /// Convenience constructor for a bare role slot. pub fn new(id: impl Into, role: impl Into) -> 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, /// Directed, typed edges. pub edges: Vec, } impl TopologyGraph { /// Build a graph and validate it in one step. pub fn new( kind: TopologyKind, nodes: Vec, edges: Vec, ) -> Result { 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>) { 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 { 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())); } }