Judge spend gained provider, model and mission on 2026-09-14; agent spend — the larger half — did not. The runtime's `done` frame has always carried `model` and `provider` beside the two token counts, and `topology_exec` read only the counts, summed them, and charged the sum as output with no record of which provider served the turn. `TurnOutcome` and `StepRecord` carry a `Spend` now (input/output split, provider, model), the worker passes it through `cm_billing::charge` along with the mission id, and the chat runtime records the model it requested — that loop drives one provider with no chain, so requested is answered. A bare model name is recorded without a guessed family. `StepRecord.spend` is `serde(default)` so journaled checkpoints from before this field still load, and `tokens` stays as the total every reader keys on. `charge` moved from `query!` to `query`: the macro pins the statement to offline metadata that a schema change then has to regenerate against a live database, for columns that are nullable text and uuid. The done-frame test now asserts the split and the provider survive, not just the sum. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
208 lines
6.0 KiB
Rust
208 lines
6.0 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![],
|
||
spend: Default::default(),
|
||
})
|
||
}
|
||
}
|
||
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());
|
||
}
|
||
}
|