Survey + fixes so the pipeline passes at the Docker level (no k8s).
- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
"Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
- `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
- clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
fleet.rs doc list indentation, node_rules map_or→is_none_or).
- Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
query → offline compile failed). DB-backed tests use testcontainers at runtime.
- Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
the committed cache deterministically (no DB needed at compile time).
- Frontend job:
- Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
tag the slice with agentId + derive null on mismatch).
- Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
current APP_IDS + use a genuinely-unknown id for the reject case).
Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
347 lines
10 KiB
Rust
347 lines
10 KiB
Rust
//! 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 serde::Serialize;
|
|
|
|
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, Serialize)]
|
|
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, Serialize)]
|
|
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);
|
|
}
|
|
}
|