refactor(topology): make the 12-kinds-to-5-patterns collapse explicit

TopologyKind describes twelve distinct intents, but the orchestrator
implements five planners and mapped the kinds onto them inside plan_steps.
So Market never auctions, StarMoe never routes to experts, Ring never cycles
and Holacratic never self-organizes -- each silently runs as whichever pattern
it collapses to, while kind::description() and the UI catalog kept promising
the distinct behaviour.

Rather than delete variants that appear in persisted rows, the collapse is now
named: ExecutionPattern + TopologyKind::execution_pattern() in cm-topology,
with plan_steps dispatching on the pattern instead of re-listing the mapping.
One source of truth, and the two cannot drift.

GET /api/topologies now reports `executes_as` and `distinct_at_execution` so a
UI can stop offering aliases as if they behaved differently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-30 10:54:43 -07:00
co-authored by Claude Opus 5
parent b1bdfbbf87
commit d94487d3ba
4 changed files with 90 additions and 13 deletions
+9
View File
@@ -33,6 +33,13 @@ pub struct CatalogEntry {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub role_distribution: Vec<RoleWeight>, pub role_distribution: Vec<RoleWeight>,
/// The execution pattern this kind actually runs as. Twelve kinds map onto
/// five patterns, so this differs from `name` for the aliased ones.
pub executes_as: String,
/// False when the kind is an alias — its description promises semantics the
/// engine does not implement (Market never auctions, Ring never cycles).
/// A UI should not offer these as if they behaved differently.
pub distinct_at_execution: bool,
} }
/// `GET /api/topologies` — the catalog of supported topology kinds. /// `GET /api/topologies` — the catalog of supported topology kinds.
@@ -53,6 +60,8 @@ pub async fn catalog(_auth: Authed) -> Json<Vec<CatalogEntry>> {
weight: *weight, weight: *weight,
}) })
.collect(), .collect(),
executes_as: kind.execution_pattern().as_str().to_string(),
distinct_at_execution: kind.is_distinct_at_execution(),
} }
}) })
.collect(); .collect();
+13 -12
View File
@@ -30,7 +30,7 @@ pub use provider_executor::ProviderExecutor;
pub use workflow::{run_workflow, WorkflowRecord}; pub use workflow::{run_workflow, WorkflowRecord};
use cm_domain::GatedCategory; use cm_domain::GatedCategory;
use cm_topology::{TopologyGraph, TopologyKind}; use cm_topology::{ExecutionPattern, TopologyGraph, TopologyKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Errors from planning or running a topology. /// Errors from planning or running a topology.
@@ -175,18 +175,19 @@ pub struct RunProgress {
pub totals: RunMetrics, pub totals: RunMetrics,
} }
/// Map every topology kind onto one of five execution patterns. The match is /// Dispatch to the planner for this graph's execution pattern.
/// exhaustive, so adding a `TopologyKind` upstream forces a decision here. ///
/// The kind→pattern collapse lives on `TopologyKind::execution_pattern` so the
/// catalog API and this dispatch cannot disagree about what a kind actually
/// does. The match is exhaustive, so adding an `ExecutionPattern` upstream
/// forces a decision here.
fn plan_steps(graph: &TopologyGraph) -> Result<Vec<plan::PlanStep>, OrchestratorError> { fn plan_steps(graph: &TopologyGraph) -> Result<Vec<plan::PlanStep>, OrchestratorError> {
Ok(match graph.kind { Ok(match graph.kind.execution_pattern() {
TopologyKind::Hierarchical ExecutionPattern::Hierarchical => plan::hierarchical(graph)?,
| TopologyKind::HubSpoke ExecutionPattern::Pipeline => plan::pipeline(graph)?,
| TopologyKind::StarMoe ExecutionPattern::Swarm => plan::swarm(graph)?,
| TopologyKind::Market => plan::hierarchical(graph)?, ExecutionPattern::Mesh => plan::mesh(graph)?,
TopologyKind::Pipeline | TopologyKind::Ring => plan::pipeline(graph)?, ExecutionPattern::Debate => plan::debate(graph)?,
TopologyKind::Swarm | TopologyKind::Flat | TopologyKind::Holacratic => plan::swarm(graph)?,
TopologyKind::Mesh | TopologyKind::Blackboard => plan::mesh(graph)?,
TopologyKind::Debate => plan::debate(graph)?,
}) })
} }
+67
View File
@@ -33,7 +33,74 @@ pub enum TopologyKind {
Holacratic, Holacratic,
} }
/// How a topology kind actually executes.
///
/// The twelve kinds above describe twelve distinct *intents*, but the
/// orchestrator implements five execution patterns and maps the kinds onto
/// them. So `Market` never auctions, `StarMoe` never routes to experts, `Ring`
/// never cycles and `Holacratic` never self-organizes — each runs as whichever
/// pattern it collapses to. Naming that here keeps the gap honest, lets the
/// catalog API report it, and makes the collapse a single source of truth that
/// `cm-orchestrator::plan_steps` matches on rather than duplicating.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionPattern {
/// Coordinator plans, members work, coordinator aggregates.
Hierarchical,
/// Each node in sequence, output feeding the next.
Pipeline,
/// All nodes in parallel, then one aggregates.
Swarm,
/// Two exchange rounds, then node 0 aggregates.
Mesh,
/// Proposer, critic, judge.
Debate,
}
impl ExecutionPattern {
pub fn as_str(&self) -> &'static str {
match self {
ExecutionPattern::Hierarchical => "hierarchical",
ExecutionPattern::Pipeline => "pipeline",
ExecutionPattern::Swarm => "swarm",
ExecutionPattern::Mesh => "mesh",
ExecutionPattern::Debate => "debate",
}
}
}
impl TopologyKind { impl TopologyKind {
/// The execution pattern this kind actually runs as.
pub fn execution_pattern(&self) -> ExecutionPattern {
match self {
TopologyKind::Hierarchical
| TopologyKind::HubSpoke
| TopologyKind::StarMoe
| TopologyKind::Market => ExecutionPattern::Hierarchical,
TopologyKind::Pipeline | TopologyKind::Ring => ExecutionPattern::Pipeline,
TopologyKind::Swarm | TopologyKind::Flat | TopologyKind::Holacratic => {
ExecutionPattern::Swarm
}
TopologyKind::Mesh | TopologyKind::Blackboard => ExecutionPattern::Mesh,
TopologyKind::Debate => ExecutionPattern::Debate,
}
}
/// Whether this kind's own semantics are realized at execution, or whether
/// it is an alias for another kind's pattern. `false` means the label is
/// currently aspirational — useful for a UI that shouldn't promise
/// behaviour the engine doesn't implement.
pub fn is_distinct_at_execution(&self) -> bool {
matches!(
self,
TopologyKind::Hierarchical
| TopologyKind::Pipeline
| TopologyKind::Swarm
| TopologyKind::Mesh
| TopologyKind::Debate
)
}
/// Every supported kind, for iteration in tests/UIs/benchmarks. /// Every supported kind, for iteration in tests/UIs/benchmarks.
pub const ALL: [TopologyKind; 12] = [ pub const ALL: [TopologyKind; 12] = [
TopologyKind::Hierarchical, TopologyKind::Hierarchical,
+1 -1
View File
@@ -26,7 +26,7 @@ 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};
pub use kind::TopologyKind; pub use kind::{ExecutionPattern, TopologyKind};
/// Errors produced while building or validating a topology. /// Errors produced while building or validating a topology.
#[derive(Debug, thiserror::Error, PartialEq, Eq)] #[derive(Debug, thiserror::Error, PartialEq, Eq)]