use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::error::TopologyError;
use crate::topology::Topology;
pub const DEFAULT_COST_WEIGHT: f32 = 0.1;
pub const DEFAULT_SURVIVOR_THRESHOLD: f32 = 0.5;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExecutionRecord {
pub task_id: String,
pub query: Vec<f32>,
pub topology: Topology,
pub utility: f32,
pub tokens: u64,
}
impl ExecutionRecord {
pub fn new(
task_id: impl Into<String>,
query: Vec<f32>,
topology: Topology,
utility: f32,
tokens: u64,
) -> Self {
Self {
task_id: task_id.into(),
query,
topology,
utility: utility.clamp(0.0, 1.0),
tokens,
}
}
}
#[derive(Debug, Clone)]
pub struct RecordSet {
records: Vec<ExecutionRecord>,
normalized_cost: Vec<f32>,
query_dim: usize,
n: usize,
embedder: Option<String>,
}
impl RecordSet {
pub fn new(records: Vec<ExecutionRecord>) -> Result<Self, TopologyError> {
if records.is_empty() {
return Err(TopologyError::NoRecords { kind: "execution" });
}
let query_dim = records[0].query.len();
let n = records[0].topology.n();
for (index, r) in records.iter().enumerate() {
if r.query.len() != query_dim {
return Err(TopologyError::QueryDimMismatch {
expected: query_dim,
found: r.query.len(),
});
}
if r.topology.n() != n {
return Err(TopologyError::SizeMismatch {
expected: n,
found: r.topology.n(),
});
}
if !r.utility.is_finite() {
return Err(TopologyError::NonFinite {
index,
field: "utility",
});
}
if r.query.iter().any(|v| !v.is_finite()) {
return Err(TopologyError::NonFinite {
index,
field: "query embedding",
});
}
}
let normalized_cost = normalize_per_task(&records);
Ok(Self {
records,
normalized_cost,
query_dim,
n,
embedder: None,
})
}
pub fn with_embedder(
records: Vec<ExecutionRecord>,
embedder: impl Into<String>,
) -> Result<Self, TopologyError> {
let mut set = Self::new(records)?;
set.embedder = Some(embedder.into());
Ok(set)
}
pub fn embedder(&self) -> Option<&str> {
self.embedder.as_deref()
}
pub fn records(&self) -> &[ExecutionRecord] {
&self.records
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn team_size(&self) -> usize {
self.n
}
pub fn query_dim(&self) -> usize {
self.query_dim
}
pub fn normalized_cost(&self, index: usize) -> f32 {
self.normalized_cost.get(index).copied().unwrap_or(1.0)
}
pub fn normalized_costs(&self) -> &[f32] {
&self.normalized_cost
}
pub fn reward(&self, index: usize, cost_weight: f32) -> f32 {
self.records[index].utility - cost_weight * self.normalized_cost(index)
}
pub fn task_ids(&self) -> Vec<String> {
let mut seen = HashSet::new();
let mut order = Vec::new();
for r in &self.records {
if seen.insert(r.task_id.as_str()) {
order.push(r.task_id.clone());
}
}
order
}
pub fn survivors(&self, threshold: f32) -> Vec<usize> {
(0..self.records.len())
.filter(|&i| self.records[i].utility > threshold)
.collect()
}
}
fn normalize_per_task(records: &[ExecutionRecord]) -> Vec<f32> {
let mut sums: HashMap<&str, (f64, usize)> = HashMap::new();
for r in records {
let entry = sums.entry(r.task_id.as_str()).or_insert((0.0, 0));
entry.0 += r.tokens as f64;
entry.1 += 1;
}
records
.iter()
.map(|r| {
let (sum, count) = sums[r.task_id.as_str()];
let mean = sum / count as f64;
if mean <= 0.0 {
1.0
} else {
(r.tokens as f64 / mean) as f32
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::topology::CoordinationShape;
fn rec(task: &str, q: f32, shape: CoordinationShape, u: f32, tokens: u64) -> ExecutionRecord {
ExecutionRecord::new(
task,
vec![q, 1.0 - q],
shape.topology(4).unwrap(),
u,
tokens,
)
}
#[test]
fn normalization_cancels_task_difficulty() {
let set = RecordSet::new(vec![
rec("easy", 0.1, CoordinationShape::Pipeline, 1.0, 100),
rec("easy", 0.1, CoordinationShape::Debate, 1.0, 300),
rec("hard", 0.9, CoordinationShape::Pipeline, 1.0, 1000),
rec("hard", 0.9, CoordinationShape::Debate, 1.0, 3000),
])
.unwrap();
assert!((set.normalized_cost(0) - set.normalized_cost(2)).abs() < 1e-6);
assert!((set.normalized_cost(1) - set.normalized_cost(3)).abs() < 1e-6);
assert!(set.normalized_cost(0) < set.normalized_cost(1));
}
#[test]
fn normalized_costs_average_to_one_within_a_task() {
let set = RecordSet::new(vec![
rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 200),
rec("t", 0.5, CoordinationShape::Debate, 1.0, 400),
rec("t", 0.5, CoordinationShape::Supervisor, 1.0, 600),
])
.unwrap();
let mean: f32 = set.normalized_costs().iter().sum::<f32>() / 3.0;
assert!((mean - 1.0).abs() < 1e-5, "got {mean}");
}
#[test]
fn zero_token_task_normalizes_to_one_not_nan() {
let set = RecordSet::new(vec![
rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 0),
rec("t", 0.5, CoordinationShape::Debate, 1.0, 0),
])
.unwrap();
assert!(set.normalized_costs().iter().all(|c| c.is_finite()));
assert_eq!(set.normalized_cost(0), 1.0);
}
#[test]
fn reward_penalizes_the_expensive_topology() {
let set = RecordSet::new(vec![
rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 100),
rec("t", 0.5, CoordinationShape::Debate, 1.0, 900),
])
.unwrap();
assert!(set.reward(0, DEFAULT_COST_WEIGHT) > set.reward(1, DEFAULT_COST_WEIGHT));
}
#[test]
fn survivors_filter_on_utility() {
let set = RecordSet::new(vec![
rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 100),
rec("t", 0.5, CoordinationShape::Debate, 0.0, 100),
rec("t", 0.5, CoordinationShape::Supervisor, 0.5, 100),
])
.unwrap();
assert_eq!(set.survivors(DEFAULT_SURVIVOR_THRESHOLD), vec![0]);
}
#[test]
fn mixed_query_dimensions_are_rejected() {
let mut a = rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 100);
let b = rec("t", 0.5, CoordinationShape::Debate, 1.0, 100);
a.query = vec![0.1, 0.2, 0.3];
assert!(matches!(
RecordSet::new(vec![a, b]),
Err(TopologyError::QueryDimMismatch { .. })
));
}
#[test]
fn mixed_team_sizes_are_rejected() {
let a = rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 100);
let b = ExecutionRecord::new(
"t",
vec![0.5, 0.5],
CoordinationShape::Pipeline.topology(5).unwrap(),
1.0,
100,
);
assert!(matches!(
RecordSet::new(vec![a, b]),
Err(TopologyError::SizeMismatch { .. })
));
}
#[test]
fn non_finite_inputs_are_rejected() {
let mut a = rec("t", 0.5, CoordinationShape::Pipeline, 1.0, 100);
a.query = vec![f32::NAN, 0.5];
assert!(matches!(
RecordSet::new(vec![a]),
Err(TopologyError::NonFinite { .. })
));
}
#[test]
fn empty_sets_are_rejected() {
assert!(matches!(
RecordSet::new(vec![]),
Err(TopologyError::NoRecords { .. })
));
}
}