Files
clawmates/crates/cm-orchestrator/src/evolve.rs
T
Omar SobhandClaude Opus 5 483de9f88a feat(billing): agent-side spend records who was paid
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
2026-09-14 08:17:20 -05:00

208 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}