Completes the scale ladder (single → team → company → org). Every tier is a
topology whose nodes are the tier below; running a parent recursively runs each
child's sub-topology down to the leaf claws.
Backend:
- migration 0011: companies/company_teams, orgs/org_companies, topology_runs.tier
- cm-db repos for companies + orgs (mirror teams)
- TurnRequest.attrs (forwarded from node.attrs) for child-id binding
- SubTopologyExecutor (recursive_exec.rs): a parent "turn" runs the child's
sub-topology; durability via parent updated_at keepalive + cancel propagation
+ depth cap; boxed future breaks the org→company recursion
- topology_worker selects executor by job.tier
- routes: /api/companies, /api/orgs (create/list/get/run) + unified
/api/structure/{level}/{id} for the zoom canvas
Frontend:
- MeshMark: node-mesh brand glyph (replaces the claw PNG), tier variants
- TopologyGraphView: optional onNodeClick/nodeMeta + dark-token theming
- StructureCanvas + Breadcrumb: one recursive zoom view for every tier
(drill down on node click, breadcrumb up); TeamRunPanel extracted + shared
- two-tier Discord-style rail: StructureRail (mesh mark + org/company/team
glyphs + tools popover + deploy + user) | RosterColumn (selected group's
children, or your claws); SecondaryNav for cross-cutting tools
- ComposeWizard (company/org) wired into DeployWizard; /companies + /orgs pages
Co-Authored-By: Claude Opus 4.8 <[email protected]>
207 lines
5.9 KiB
Rust
207 lines
5.9 KiB
Rust
//! 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());
|
||
}
|
||
}
|