feat(topology): template builders + evolution/QD search (P5)
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:
co-authored by
Claude Opus 4.8
parent
bd982943b8
commit
b0c122b88d
@@ -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(", "));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user