vortex-trace 0.1.0

Structured event tracing and replay for Vortex simulations
Documentation
//! Simulation statistics computed from trace events.
//!
//! Aggregate counters and latency percentiles extracted from a [`SimTrace`].

use crate::{SimTrace, TraceEventKind};

/// Aggregate statistics from a simulation run.
#[derive(Debug, Clone, Default)]
pub struct SimStats {
    pub messages_sent: u64,
    pub messages_delivered: u64,
    pub messages_dropped: u64,
    pub state_transitions: u64,
    pub faults_injected: u64,
    pub faults_healed: u64,
    pub storage_ops: u64,
    pub timers_fired: u64,
    pub custom_events: u64,
    /// Latency samples (in ticks) for percentile computation.
    latency_samples: Vec<u64>,
}

impl SimStats {
    /// Compute statistics from a simulation trace.
    pub fn from_trace(trace: &SimTrace) -> Self {
        let mut stats = Self::default();

        for event in trace.events() {
            match &event.kind {
                TraceEventKind::MessageSent { .. } => stats.messages_sent += 1,
                TraceEventKind::MessageDelivered { .. } => stats.messages_delivered += 1,
                TraceEventKind::MessageDropped { .. } => stats.messages_dropped += 1,
                TraceEventKind::StateTransition { .. } => stats.state_transitions += 1,
                TraceEventKind::FaultInjected { .. } => stats.faults_injected += 1,
                TraceEventKind::FaultHealed { .. } => stats.faults_healed += 1,
                TraceEventKind::StorageOp { .. } => stats.storage_ops += 1,
                TraceEventKind::TimerFired { .. } => stats.timers_fired += 1,
                TraceEventKind::Custom { .. } => stats.custom_events += 1,
            }
        }

        // Compute message latencies from send/deliver pairs.
        // For each MessageDelivered, walk backward to find the matching MessageSent
        // with same msg_type from the delivering node's perspective.
        let events = trace.events();
        for (i, event) in events.iter().enumerate() {
            if let TraceEventKind::MessageDelivered { from, msg_type, .. } = &event.kind {
                for j in (0..i).rev() {
                    if let TraceEventKind::MessageSent {
                        to, msg_type: st, ..
                    } = &events[j].kind
                        && *to == event.node_id
                        && st == msg_type
                        && events[j].node_id == *from
                    {
                        let latency = event.tick.saturating_sub(events[j].tick);
                        stats.latency_samples.push(latency);
                        break;
                    }
                }
            }
        }

        stats.latency_samples.sort();
        stats
    }

    /// Percentile latency (0–100).
    pub fn latency_percentile(&self, p: f64) -> u64 {
        if self.latency_samples.is_empty() {
            return 0;
        }
        let idx = ((p / 100.0) * (self.latency_samples.len() - 1) as f64).round() as usize;
        let idx = idx.min(self.latency_samples.len() - 1);
        self.latency_samples[idx]
    }

    /// P50 latency.
    pub fn p50(&self) -> u64 {
        self.latency_percentile(50.0)
    }
    /// P95 latency.
    pub fn p95(&self) -> u64 {
        self.latency_percentile(95.0)
    }
    /// P99 latency.
    pub fn p99(&self) -> u64 {
        self.latency_percentile(99.0)
    }

    /// Average latency.
    pub fn avg_latency(&self) -> f64 {
        if self.latency_samples.is_empty() {
            return 0.0;
        }
        self.latency_samples.iter().sum::<u64>() as f64 / self.latency_samples.len() as f64
    }

    /// Number of latency samples collected.
    pub fn latency_sample_count(&self) -> usize {
        self.latency_samples.len()
    }

    /// Human-readable summary.
    pub fn summary(&self) -> String {
        let mut lines = Vec::new();
        lines.push("=== Simulation Statistics ===".to_string());
        lines.push(format!(
            "Messages: sent={}, delivered={}, dropped={}",
            self.messages_sent, self.messages_delivered, self.messages_dropped
        ));
        lines.push(format!("State transitions: {}", self.state_transitions));
        lines.push(format!("Storage ops: {}", self.storage_ops));
        lines.push(format!("Timers fired: {}", self.timers_fired));
        lines.push(format!(
            "Faults: injected={}, healed={}",
            self.faults_injected, self.faults_healed
        ));
        if !self.latency_samples.is_empty() {
            lines.push(format!(
                "Latency (ticks): avg={:.1}, p50={}, p95={}, p99={}",
                self.avg_latency(),
                self.p50(),
                self.p95(),
                self.p99()
            ));
        }
        lines.join("\n")
    }

    /// Machine-readable JSON output.
    pub fn to_json(&self) -> String {
        serde_json::json!({
            "messages_sent": self.messages_sent,
            "messages_delivered": self.messages_delivered,
            "messages_dropped": self.messages_dropped,
            "state_transitions": self.state_transitions,
            "faults_injected": self.faults_injected,
            "faults_healed": self.faults_healed,
            "storage_ops": self.storage_ops,
            "timers_fired": self.timers_fired,
            "custom_events": self.custom_events,
            "latency_avg": self.avg_latency(),
            "latency_p50": self.p50(),
            "latency_p95": self.p95(),
            "latency_p99": self.p99(),
            "latency_samples": self.latency_samples.len(),
        })
        .to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::SimTrace;

    fn build_test_trace() -> SimTrace {
        let mut trace = SimTrace::new();
        trace.record(
            1,
            1,
            TraceEventKind::MessageSent {
                to: 2,
                msg_type: "AppendEntries".into(),
                size_bytes: 100,
            },
        );
        trace.record(
            2,
            2,
            TraceEventKind::MessageDelivered {
                from: 1,
                msg_type: "AppendEntries".into(),
                size_bytes: 100,
            },
        );
        trace.record(
            3,
            1,
            TraceEventKind::MessageSent {
                to: 2,
                msg_type: "Heartbeat".into(),
                size_bytes: 10,
            },
        );
        trace.record(
            5,
            2,
            TraceEventKind::MessageDelivered {
                from: 1,
                msg_type: "Heartbeat".into(),
                size_bytes: 10,
            },
        );
        trace.record(
            10,
            1,
            TraceEventKind::StateTransition {
                from_state: "Follower".into(),
                to_state: "Leader".into(),
                metadata: "term=1".into(),
            },
        );
        trace.record(
            15,
            0,
            TraceEventKind::FaultInjected {
                fault_type: "partition".into(),
                details: "test".into(),
            },
        );
        trace.record(
            20,
            0,
            TraceEventKind::FaultHealed {
                fault_type: "partition".into(),
                details: "test".into(),
            },
        );
        trace.record(
            25,
            1,
            TraceEventKind::StorageOp {
                op_type: "put".into(),
                key_count: 5,
            },
        );
        trace.record(
            30,
            1,
            TraceEventKind::TimerFired {
                timer_type: "election".into(),
            },
        );
        trace.record(
            35,
            0,
            TraceEventKind::MessageDropped {
                from: 1,
                to: 3,
                reason: "partition".into(),
            },
        );
        trace
    }

    #[test]
    fn test_stats_message_counts() {
        let trace = build_test_trace();
        let stats = SimStats::from_trace(&trace);
        assert_eq!(stats.messages_sent, 2);
        assert_eq!(stats.messages_delivered, 2);
        assert_eq!(stats.messages_dropped, 1);
    }

    #[test]
    fn test_stats_event_counts() {
        let trace = build_test_trace();
        let stats = SimStats::from_trace(&trace);
        assert_eq!(stats.state_transitions, 1);
        assert_eq!(stats.faults_injected, 1);
        assert_eq!(stats.faults_healed, 1);
        assert_eq!(stats.storage_ops, 1);
        assert_eq!(stats.timers_fired, 1);
    }

    #[test]
    fn test_stats_latency_percentiles() {
        let trace = build_test_trace();
        let stats = SimStats::from_trace(&trace);
        // Two latency samples: tick 2-1=1, tick 5-3=2
        assert_eq!(stats.latency_sample_count(), 2);
        assert!(stats.p50() >= 1 && stats.p50() <= 2);
        assert_eq!(stats.p99(), 2);
        assert!((stats.avg_latency() - 1.5).abs() < 0.01);
    }

    #[test]
    fn test_stats_summary_contains_metrics() {
        let trace = build_test_trace();
        let stats = SimStats::from_trace(&trace);
        let summary = stats.summary();
        assert!(summary.contains("sent=2"));
        assert!(summary.contains("delivered=2"));
        assert!(summary.contains("dropped=1"));
        assert!(summary.contains("State transitions: 1"));
    }

    #[test]
    fn test_stats_json_valid() {
        let trace = build_test_trace();
        let stats = SimStats::from_trace(&trace);
        let json = stats.to_json();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["messages_sent"], 2);
        assert_eq!(parsed["messages_delivered"], 2);
        assert_eq!(parsed["timers_fired"], 1);
    }

    #[test]
    fn test_stats_empty_trace() {
        let trace = SimTrace::new();
        let stats = SimStats::from_trace(&trace);
        assert_eq!(stats.messages_sent, 0);
        assert_eq!(stats.p50(), 0);
        assert_eq!(stats.avg_latency(), 0.0);
    }
}