use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::events::ActionEvent;
#[derive(Debug, Clone, Default)]
pub struct SwarmStats {
action_stats: HashMap<String, ActionStats>,
action_target_stats: HashMap<(String, String), ActionStats>,
global: GlobalStats,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActionStats {
pub visits: u32,
pub successes: u32,
pub failures: u32,
pub discoveries: u32,
#[serde(
serialize_with = "serialize_duration",
deserialize_with = "deserialize_duration"
)]
pub total_duration: Duration,
#[serde(default)]
pub kpi_total: f64,
#[serde(default)]
pub kpi_count: u32,
}
fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_u64(duration.as_millis() as u64)
}
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
let millis = u64::deserialize(deserializer)?;
Ok(Duration::from_millis(millis))
}
impl ActionStats {
pub fn success_rate(&self) -> f64 {
if self.visits == 0 {
0.5
} else {
self.successes as f64 / self.visits as f64
}
}
pub fn avg_duration(&self) -> Duration {
if self.visits == 0 {
Duration::ZERO
} else {
self.total_duration / self.visits
}
}
pub fn avg_discoveries(&self) -> f64 {
if self.visits == 0 {
0.0
} else {
self.discoveries as f64 / self.visits as f64
}
}
pub fn avg_kpi(&self) -> f64 {
if self.kpi_count == 0 {
0.0
} else {
self.kpi_total / self.kpi_count as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GlobalStats {
pub total_visits: u32,
pub total_successes: u32,
pub total_failures: u32,
pub total_discoveries: u32,
pub total_duration: Duration,
pub total_kpi: f64,
pub kpi_count: u32,
}
impl GlobalStats {
pub fn success_rate(&self) -> f64 {
if self.total_visits == 0 {
1.0
} else {
self.total_successes as f64 / self.total_visits as f64
}
}
pub fn failure_rate(&self) -> f64 {
if self.total_visits == 0 {
0.0
} else {
self.total_failures as f64 / self.total_visits as f64
}
}
pub fn avg_kpi(&self) -> f64 {
if self.kpi_count == 0 {
0.0
} else {
self.total_kpi / self.kpi_count as f64
}
}
}
impl SwarmStats {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, event: &ActionEvent) {
let action = &event.action;
let success = event.result.success;
let duration = event.duration;
if action == "llm_invoke" {
return;
}
if event.worker_id.is_manager() {
return;
}
if action == "tick_start" || action == "tick_end" {
return;
}
let target = event.target.as_deref();
let discoveries = event.result.discoveries;
let kpi = event.result.kpi_contribution;
self.global.total_visits += 1;
self.global.total_duration += duration;
if success {
self.global.total_successes += 1;
self.global.total_discoveries += discoveries;
} else {
self.global.total_failures += 1;
}
if let Some(k) = kpi {
self.global.total_kpi += k;
self.global.kpi_count += 1;
}
let action_stat = self.action_stats.entry(action.clone()).or_default();
action_stat.visits += 1;
action_stat.total_duration += duration;
if success {
action_stat.successes += 1;
action_stat.discoveries += discoveries;
} else {
action_stat.failures += 1;
}
if let Some(k) = kpi {
action_stat.kpi_total += k;
action_stat.kpi_count += 1;
}
if let Some(t) = target {
let at_stat = self
.action_target_stats
.entry((action.clone(), t.to_string()))
.or_default();
at_stat.visits += 1;
at_stat.total_duration += duration;
if success {
at_stat.successes += 1;
at_stat.discoveries += discoveries;
} else {
at_stat.failures += 1;
}
if let Some(k) = kpi {
at_stat.kpi_total += k;
at_stat.kpi_count += 1;
}
}
}
pub fn get_action_stats(&self, action: &str) -> ActionStats {
self.action_stats.get(action).cloned().unwrap_or_default()
}
pub fn get_action_target_stats(&self, action: &str, target: &str) -> ActionStats {
self.action_target_stats
.get(&(action.to_string(), target.to_string()))
.cloned()
.unwrap_or_default()
}
pub fn global(&self) -> &GlobalStats {
&self.global
}
pub fn total_visits(&self) -> u32 {
self.global.total_visits
}
pub fn total_successes(&self) -> u32 {
self.global.total_successes
}
pub fn total_failures(&self) -> u32 {
self.global.total_failures
}
pub fn success_rate(&self) -> f64 {
self.global.success_rate()
}
pub fn failure_rate(&self) -> f64 {
self.global.failure_rate()
}
pub fn all_action_stats(&self) -> impl Iterator<Item = (&String, &ActionStats)> {
self.action_stats.iter()
}
pub fn reset(&mut self) {
self.action_stats.clear();
self.action_target_stats.clear();
self.global = GlobalStats::default();
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::events::{ActionEventBuilder, ActionEventResult};
use crate::types::WorkerId;
fn make_event(action: &str, target: Option<&str>, success: bool) -> ActionEvent {
let mut builder =
ActionEventBuilder::new(1, WorkerId(0), action).duration(Duration::from_millis(100));
if let Some(t) = target {
builder = builder.target(t);
}
let result = if success {
ActionEventResult::success()
} else {
ActionEventResult::failure("error")
};
builder.result(result).build()
}
#[test]
fn test_swarm_stats_basic() {
let mut stats = SwarmStats::new();
stats.record(&make_event("CheckStatus", Some("svc1"), true));
stats.record(&make_event("CheckStatus", Some("svc1"), true));
stats.record(&make_event("CheckStatus", Some("svc2"), false));
let action_stats = stats.get_action_stats("CheckStatus");
assert_eq!(action_stats.visits, 3);
assert_eq!(action_stats.successes, 2);
assert_eq!(action_stats.failures, 1);
assert!((action_stats.success_rate() - 0.666).abs() < 0.01);
let at_stats = stats.get_action_target_stats("CheckStatus", "svc1");
assert_eq!(at_stats.visits, 2);
assert_eq!(at_stats.successes, 2);
}
#[test]
fn test_swarm_stats_global() {
let mut stats = SwarmStats::new();
stats.record(&make_event("A", None, true));
stats.record(&make_event("B", None, true));
stats.record(&make_event("C", None, false));
assert_eq!(stats.total_visits(), 3);
assert_eq!(stats.total_successes(), 2);
assert_eq!(stats.total_failures(), 1);
assert!((stats.success_rate() - 0.666).abs() < 0.01);
}
#[test]
fn test_llm_invoke_skipped() {
let mut stats = SwarmStats::new();
let e = ActionEventBuilder::new(1, WorkerId(0), "llm_invoke")
.result(ActionEventResult::success())
.build();
stats.record(&e);
assert_eq!(stats.total_visits(), 0);
}
#[test]
fn test_manager_skipped() {
let mut stats = SwarmStats::new();
let e = ActionEventBuilder::new(1, WorkerId::MANAGER, "decide")
.result(ActionEventResult::success())
.build();
stats.record(&e);
assert_eq!(stats.total_visits(), 0);
}
#[test]
fn test_kpi_contribution() {
let mut stats = SwarmStats::new();
stats.record(&make_event("A", None, true));
let e1 = ActionEventBuilder::new(1, WorkerId(0), "B")
.result(ActionEventResult::success().with_kpi(0.5))
.build();
stats.record(&e1);
let e2 = ActionEventBuilder::new(2, WorkerId(0), "B")
.result(ActionEventResult::success().with_kpi(0.8))
.build();
stats.record(&e2);
assert_eq!(stats.global().kpi_count, 2);
assert!((stats.global().total_kpi - 1.3).abs() < 0.01);
assert!((stats.global().avg_kpi() - 0.65).abs() < 0.01);
let a_stats = stats.get_action_stats("A");
assert_eq!(a_stats.kpi_count, 0);
assert_eq!(a_stats.avg_kpi(), 0.0);
let b_stats = stats.get_action_stats("B");
assert_eq!(b_stats.kpi_count, 2);
assert!((b_stats.kpi_total - 1.3).abs() < 0.01);
assert!((b_stats.avg_kpi() - 0.65).abs() < 0.01);
}
}