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]>
116 lines
3.3 KiB
Rust
116 lines
3.3 KiB
Rust
//! 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(_))));
|
|
}
|
|
}
|