feat(topology): template builders + evolution/QD search (P5)
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

cm-topology::build(kind, roles) instantiates a canonical graph for any of the 12
kinds from a role list (the template catalog + the search generator).

cm-orchestrator::evolve searches a (kinds × team-size) grid using the comparison
machinery as fitness: build a candidate per cell, run the task, score it, and
keep a MAP-Elites-style archive of per-cell elites + a quality/cost Pareto front
and the global best. evolve_all() covers every kind at full size. This is the
bridge toward Autonomous Organizational Evolution on a safe substrate — every
candidate still executes via safe turns (§15 invariant holds).

Demoed in topology_bench (auto-picks the best topology + Pareto kinds).
cm-topology 20 tests; cm-orchestrator 17 (--features provider); clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-15 21:36:01 -07:00
co-authored by Claude Opus 4.8
parent bd982943b8
commit b0c122b88d
5 changed files with 320 additions and 1 deletions
@@ -15,7 +15,7 @@
use std::sync::Arc; use std::sync::Arc;
use cm_llm::LlmProvider; use cm_llm::LlmProvider;
use cm_orchestrator::{compare, run_workflow, ProviderExecutor, RunRecord, Scorer}; use cm_orchestrator::{compare, evolve_all, run_workflow, ProviderExecutor, RunRecord, Scorer};
use cm_topology::{Edge, EdgeKind, Node, TopologyGraph, TopologyKind}; use cm_topology::{Edge, EdgeKind, Node, TopologyGraph, TopologyKind};
/// Deterministic quality proxy: richer (longer) output scores higher. /// Deterministic quality proxy: richer (longer) output scores higher.
@@ -142,4 +142,25 @@ async fn main() {
wf.totals.turns, wf.totals.turns,
wf.totals.tokens wf.totals.tokens
); );
// Evolution: search every topology kind for the best fit for this task.
let roles = ["coordinator", "researcher", "analyst", "writer"];
let ev = evolve_all(&roles, task, &executor, &LengthScorer)
.await
.expect("evolution failed");
println!("\nevolution: searched {} topologies", ev.evaluated);
if let Some(i) = ev.best {
let b = &ev.archive[i];
println!(
" best: {:?} (size {}) quality {:.2} tokens {}",
b.kind, b.size, b.quality, b.tokens
);
}
let pareto: Vec<String> = ev
.archive
.iter()
.filter(|c| c.on_pareto)
.map(|c| format!("{:?}", c.kind))
.collect();
println!(" pareto-optimal: {}", pareto.join(", "));
} }
+183
View File
@@ -0,0 +1,183 @@
//! Topology search / quality-diversity (P5).
//!
//! Uses the comparison machinery as a fitness function: generate a candidate
//! topology for each (kind × team-size) cell, run the task, score it, and keep
//! a MAP-Elites-style **archive** of the best elite per cell plus a quality/cost
//! Pareto front. This *illuminates* the search space (which structures work for
//! a task) rather than returning a single winner — the bridge toward
//! Autonomous Organizational Evolution on a safe substrate.
//!
//! v1 enumerates the grid deterministically; mutation/selection across
//! generations slot in by repeatedly evaluating and keeping per-cell bests.
use std::cmp::Ordering;
use serde::Serialize;
use cm_topology::{build, TopologyKind};
use crate::{execute, OrchestratorError, Scorer, TurnExecutor};
/// The best result found for one (kind × size) cell.
#[derive(Debug, Clone, Serialize)]
pub struct EliteCell {
/// Topology kind for this cell.
pub kind: TopologyKind,
/// Team size (node count) for this cell.
pub size: usize,
/// Quality in `[0,1]`.
pub quality: f64,
/// Tokens spent (cost proxy).
pub tokens: u64,
/// Turns run.
pub turns: u32,
/// The cell's final output.
pub final_output: String,
/// Whether this cell is on the quality/cost Pareto front.
pub on_pareto: bool,
}
/// The result of a topology search.
#[derive(Debug, Clone, Serialize)]
pub struct Evolution {
/// The task searched against.
pub task: String,
/// One elite per evaluated (kind × size) cell.
pub archive: Vec<EliteCell>,
/// Index of the highest-quality elite, if any.
pub best: Option<usize>,
/// Number of candidate topologies evaluated.
pub evaluated: u32,
}
/// Search the (kinds × sizes) grid for the best topology for `task`. Sizes
/// larger than `roles.len()` (or zero) are skipped; `roles[..size]` staffs each
/// candidate.
pub async fn evolve<E: TurnExecutor, S: Scorer>(
roles: &[&str],
task: &str,
executor: &E,
scorer: &S,
kinds: &[TopologyKind],
sizes: &[usize],
) -> Result<Evolution, OrchestratorError> {
let mut archive: Vec<EliteCell> = Vec::new();
let mut evaluated = 0u32;
for &kind in kinds {
for &size in sizes {
if size == 0 || size > roles.len() {
continue;
}
let graph = build(kind, &roles[..size])
.map_err(|e| OrchestratorError::Malformed(e.to_string()))?;
let rec = execute(&graph, task, executor).await?;
let quality = scorer.score(task, &rec).await.clamp(0.0, 1.0);
evaluated += 1;
archive.push(EliteCell {
kind,
size,
quality,
tokens: rec.totals.tokens,
turns: rec.totals.turns,
final_output: rec.final_output,
on_pareto: false,
});
}
}
// Pareto: maximize quality, minimize tokens.
for i in 0..archive.len() {
let dominated = archive.iter().enumerate().any(|(j, o)| {
j != i
&& o.quality >= archive[i].quality
&& o.tokens <= archive[i].tokens
&& (o.quality > archive[i].quality || o.tokens < archive[i].tokens)
});
archive[i].on_pareto = !dominated;
}
let best = archive
.iter()
.enumerate()
.max_by(|a, b| a.1.quality.partial_cmp(&b.1.quality).unwrap_or(Ordering::Equal))
.map(|(i, _)| i);
Ok(Evolution {
task: task.to_string(),
archive,
best,
evaluated,
})
}
/// Convenience: search every kind at the full team size.
pub async fn evolve_all<E: TurnExecutor, S: Scorer>(
roles: &[&str],
task: &str,
executor: &E,
scorer: &S,
) -> Result<Evolution, OrchestratorError> {
evolve(roles, task, executor, scorer, &TopologyKind::ALL, &[roles.len()]).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RunRecord, TurnOutcome, TurnRequest};
struct Echo;
impl TurnExecutor for Echo {
async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> {
Ok(TurnOutcome {
output: format!("{}<{}>", req.role, req.context.join("|")),
tokens: 10,
gated: vec![],
})
}
}
struct LengthScorer;
impl Scorer for LengthScorer {
async fn score(&self, _t: &str, r: &RunRecord) -> f64 {
(r.final_output.len() as f64 / 200.0).min(1.0)
}
}
#[tokio::test]
async fn searches_kinds_and_marks_pareto() {
let roles = ["coordinator", "a", "b"];
let ev = evolve(
&roles,
"task",
&Echo,
&LengthScorer,
&[TopologyKind::Hierarchical, TopologyKind::Pipeline, TopologyKind::Swarm],
&[3],
)
.await
.unwrap();
assert_eq!(ev.archive.len(), 3);
assert_eq!(ev.evaluated, 3);
assert!(ev.best.is_some());
assert!(ev.archive.iter().any(|c| c.on_pareto));
}
#[tokio::test]
async fn grid_spans_sizes_and_skips_oversized() {
let roles = ["a", "b", "c"];
let ev = evolve(&roles, "t", &Echo, &LengthScorer, &[TopologyKind::Pipeline], &[2, 3, 9])
.await
.unwrap();
// sizes 2 and 3 evaluated; 9 skipped (> roles.len()).
assert_eq!(ev.evaluated, 2);
assert_eq!(ev.archive.len(), 2);
}
#[tokio::test]
async fn evolve_all_covers_every_kind() {
let roles = ["a", "b", "c"];
let ev = evolve_all(&roles, "t", &Echo, &LengthScorer).await.unwrap();
assert_eq!(ev.evaluated as usize, TopologyKind::ALL.len());
}
}
+2
View File
@@ -12,6 +12,7 @@
//! [`TurnExecutor`], so the control flow is fully testable with a scripted //! [`TurnExecutor`], so the control flow is fully testable with a scripted
//! executor and is independent of the LLM/runtime. //! executor and is independent of the LLM/runtime.
mod evolve;
mod harness; mod harness;
mod plan; mod plan;
mod workflow; mod workflow;
@@ -20,6 +21,7 @@ mod judge;
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
mod provider_executor; mod provider_executor;
pub use evolve::{evolve, evolve_all, EliteCell, Evolution};
pub use harness::{compare, Comparison, Scorer, TopologyResult}; pub use harness::{compare, Comparison, Scorer, TopologyResult};
pub use workflow::{run_workflow, WorkflowRecord}; pub use workflow::{run_workflow, WorkflowRecord};
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
+111
View File
@@ -0,0 +1,111 @@
//! Canonical topology builders: instantiate a [`TopologyGraph`] of any
//! [`TopologyKind`] from a list of roles.
//!
//! This is the "template catalog" — it turns a kind + roles into a runnable
//! graph, and it is the candidate generator for the evolution/search layer.
//! Node ids are `n0..nk`; `roles[i]` is the role of `ni`.
use crate::graph::{Edge, EdgeKind, Node, TopologyGraph};
use crate::kind::TopologyKind;
use crate::TopologyError;
fn id(i: usize) -> String {
format!("n{i}")
}
fn star(n: usize, kind: EdgeKind) -> Vec<Edge> {
(1..n)
.map(|i| Edge { from: id(0), to: id(i), kind })
.collect()
}
fn chain(n: usize, close: bool) -> Vec<Edge> {
let mut edges: Vec<Edge> = (0..n.saturating_sub(1))
.map(|i| Edge { from: id(i), to: id(i + 1), kind: EdgeKind::PipesTo })
.collect();
if close && n > 1 {
edges.push(Edge { from: id(n - 1), to: id(0), kind: EdgeKind::PipesTo });
}
edges
}
fn complete(n: usize, kind: EdgeKind) -> Vec<Edge> {
let mut edges = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
edges.push(Edge { from: id(i), to: id(j), kind });
}
}
edges
}
/// Build a canonical graph of `kind` over `roles` (node `ni` plays `roles[i]`).
pub fn build(kind: TopologyKind, roles: &[&str]) -> Result<TopologyGraph, TopologyError> {
if roles.is_empty() {
return Err(TopologyError::Empty);
}
let n = roles.len();
let nodes: Vec<Node> = roles
.iter()
.enumerate()
.map(|(i, r)| Node::new(id(i), *r))
.collect();
use TopologyKind::*;
let edges = match kind {
Hierarchical => star(n, EdgeKind::DelegatesTo),
HubSpoke | StarMoe => star(n, EdgeKind::RoutesTo),
Market => star(n, EdgeKind::BidsTo),
Pipeline => chain(n, false),
Ring => chain(n, true),
Mesh => complete(n, EdgeKind::PeersWith),
Blackboard => complete(n, EdgeKind::ReadsWrites),
// Peer/parallel kinds wire structure at run time, not via edges.
Flat | Holacratic | Swarm | Debate => Vec::new(),
};
TopologyGraph::new(kind, nodes, edges)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classify;
#[test]
fn builds_every_kind() {
let roles = ["coordinator", "researcher", "writer"];
for kind in TopologyKind::ALL {
let g = build(kind, &roles).expect("build");
assert_eq!(g.order(), 3);
assert_eq!(g.kind, kind);
g.validate().expect("valid");
}
}
#[test]
fn canonical_shapes_classify_back() {
let roles = ["a", "b", "c", "d"];
assert_eq!(
classify(&build(TopologyKind::Hierarchical, &roles).unwrap()).primary,
TopologyKind::HubSpoke // a 1→(n-1) star reads as hub/star structurally
);
assert_eq!(
classify(&build(TopologyKind::Pipeline, &roles).unwrap()).primary,
TopologyKind::Pipeline
);
assert_eq!(
classify(&build(TopologyKind::Ring, &roles).unwrap()).primary,
TopologyKind::Ring
);
assert_eq!(
classify(&build(TopologyKind::Mesh, &roles).unwrap()).primary,
TopologyKind::Mesh
);
}
#[test]
fn empty_roles_error() {
assert_eq!(build(TopologyKind::Flat, &[]), Err(TopologyError::Empty));
}
}
+2
View File
@@ -15,12 +15,14 @@
//! - [`heuristics`]— per-kind role distributions / optimization weights. //! - [`heuristics`]— per-kind role distributions / optimization weights.
mod adapter; mod adapter;
mod builders;
mod classifier; mod classifier;
mod graph; mod graph;
mod heuristics; mod heuristics;
mod kind; mod kind;
pub use adapter::{from_json, to_json}; pub use adapter::{from_json, to_json};
pub use builders::build;
pub use classifier::{classify, Classification, GraphMetrics}; pub use classifier::{classify, Classification, GraphMetrics};
pub use graph::{Edge, EdgeKind, Node, TopologyGraph}; pub use graph::{Edge, EdgeKind, Node, TopologyGraph};
pub use heuristics::{heuristics, Heuristics}; pub use heuristics::{heuristics, Heuristics};