Skip to main content

vortex_trace/
stats.rs

1//! Simulation statistics computed from trace events.
2//!
3//! Aggregate counters and latency percentiles extracted from a [`SimTrace`].
4
5use crate::{SimTrace, TraceEventKind};
6
7/// Aggregate statistics from a simulation run.
8#[derive(Debug, Clone, Default)]
9pub struct SimStats {
10    pub messages_sent: u64,
11    pub messages_delivered: u64,
12    pub messages_dropped: u64,
13    pub state_transitions: u64,
14    pub faults_injected: u64,
15    pub faults_healed: u64,
16    pub storage_ops: u64,
17    pub timers_fired: u64,
18    pub custom_events: u64,
19    /// Latency samples (in ticks) for percentile computation.
20    latency_samples: Vec<u64>,
21}
22
23impl SimStats {
24    /// Compute statistics from a simulation trace.
25    pub fn from_trace(trace: &SimTrace) -> Self {
26        let mut stats = Self::default();
27
28        for event in trace.events() {
29            match &event.kind {
30                TraceEventKind::MessageSent { .. } => stats.messages_sent += 1,
31                TraceEventKind::MessageDelivered { .. } => stats.messages_delivered += 1,
32                TraceEventKind::MessageDropped { .. } => stats.messages_dropped += 1,
33                TraceEventKind::StateTransition { .. } => stats.state_transitions += 1,
34                TraceEventKind::FaultInjected { .. } => stats.faults_injected += 1,
35                TraceEventKind::FaultHealed { .. } => stats.faults_healed += 1,
36                TraceEventKind::StorageOp { .. } => stats.storage_ops += 1,
37                TraceEventKind::TimerFired { .. } => stats.timers_fired += 1,
38                TraceEventKind::Custom { .. } => stats.custom_events += 1,
39            }
40        }
41
42        // Compute message latencies from send/deliver pairs.
43        // For each MessageDelivered, walk backward to find the matching MessageSent
44        // with same msg_type from the delivering node's perspective.
45        let events = trace.events();
46        for (i, event) in events.iter().enumerate() {
47            if let TraceEventKind::MessageDelivered { from, msg_type, .. } = &event.kind {
48                for j in (0..i).rev() {
49                    if let TraceEventKind::MessageSent {
50                        to, msg_type: st, ..
51                    } = &events[j].kind
52                        && *to == event.node_id
53                        && st == msg_type
54                        && events[j].node_id == *from
55                    {
56                        let latency = event.tick.saturating_sub(events[j].tick);
57                        stats.latency_samples.push(latency);
58                        break;
59                    }
60                }
61            }
62        }
63
64        stats.latency_samples.sort();
65        stats
66    }
67
68    /// Percentile latency (0–100).
69    pub fn latency_percentile(&self, p: f64) -> u64 {
70        if self.latency_samples.is_empty() {
71            return 0;
72        }
73        let idx = ((p / 100.0) * (self.latency_samples.len() - 1) as f64).round() as usize;
74        let idx = idx.min(self.latency_samples.len() - 1);
75        self.latency_samples[idx]
76    }
77
78    /// P50 latency.
79    pub fn p50(&self) -> u64 {
80        self.latency_percentile(50.0)
81    }
82    /// P95 latency.
83    pub fn p95(&self) -> u64 {
84        self.latency_percentile(95.0)
85    }
86    /// P99 latency.
87    pub fn p99(&self) -> u64 {
88        self.latency_percentile(99.0)
89    }
90
91    /// Average latency.
92    pub fn avg_latency(&self) -> f64 {
93        if self.latency_samples.is_empty() {
94            return 0.0;
95        }
96        self.latency_samples.iter().sum::<u64>() as f64 / self.latency_samples.len() as f64
97    }
98
99    /// Number of latency samples collected.
100    pub fn latency_sample_count(&self) -> usize {
101        self.latency_samples.len()
102    }
103
104    /// Human-readable summary.
105    pub fn summary(&self) -> String {
106        let mut lines = Vec::new();
107        lines.push("=== Simulation Statistics ===".to_string());
108        lines.push(format!(
109            "Messages: sent={}, delivered={}, dropped={}",
110            self.messages_sent, self.messages_delivered, self.messages_dropped
111        ));
112        lines.push(format!("State transitions: {}", self.state_transitions));
113        lines.push(format!("Storage ops: {}", self.storage_ops));
114        lines.push(format!("Timers fired: {}", self.timers_fired));
115        lines.push(format!(
116            "Faults: injected={}, healed={}",
117            self.faults_injected, self.faults_healed
118        ));
119        if !self.latency_samples.is_empty() {
120            lines.push(format!(
121                "Latency (ticks): avg={:.1}, p50={}, p95={}, p99={}",
122                self.avg_latency(),
123                self.p50(),
124                self.p95(),
125                self.p99()
126            ));
127        }
128        lines.join("\n")
129    }
130
131    /// Machine-readable JSON output.
132    pub fn to_json(&self) -> String {
133        serde_json::json!({
134            "messages_sent": self.messages_sent,
135            "messages_delivered": self.messages_delivered,
136            "messages_dropped": self.messages_dropped,
137            "state_transitions": self.state_transitions,
138            "faults_injected": self.faults_injected,
139            "faults_healed": self.faults_healed,
140            "storage_ops": self.storage_ops,
141            "timers_fired": self.timers_fired,
142            "custom_events": self.custom_events,
143            "latency_avg": self.avg_latency(),
144            "latency_p50": self.p50(),
145            "latency_p95": self.p95(),
146            "latency_p99": self.p99(),
147            "latency_samples": self.latency_samples.len(),
148        })
149        .to_string()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::SimTrace;
157
158    fn build_test_trace() -> SimTrace {
159        let mut trace = SimTrace::new();
160        trace.record(
161            1,
162            1,
163            TraceEventKind::MessageSent {
164                to: 2,
165                msg_type: "AppendEntries".into(),
166                size_bytes: 100,
167            },
168        );
169        trace.record(
170            2,
171            2,
172            TraceEventKind::MessageDelivered {
173                from: 1,
174                msg_type: "AppendEntries".into(),
175                size_bytes: 100,
176            },
177        );
178        trace.record(
179            3,
180            1,
181            TraceEventKind::MessageSent {
182                to: 2,
183                msg_type: "Heartbeat".into(),
184                size_bytes: 10,
185            },
186        );
187        trace.record(
188            5,
189            2,
190            TraceEventKind::MessageDelivered {
191                from: 1,
192                msg_type: "Heartbeat".into(),
193                size_bytes: 10,
194            },
195        );
196        trace.record(
197            10,
198            1,
199            TraceEventKind::StateTransition {
200                from_state: "Follower".into(),
201                to_state: "Leader".into(),
202                metadata: "term=1".into(),
203            },
204        );
205        trace.record(
206            15,
207            0,
208            TraceEventKind::FaultInjected {
209                fault_type: "partition".into(),
210                details: "test".into(),
211            },
212        );
213        trace.record(
214            20,
215            0,
216            TraceEventKind::FaultHealed {
217                fault_type: "partition".into(),
218                details: "test".into(),
219            },
220        );
221        trace.record(
222            25,
223            1,
224            TraceEventKind::StorageOp {
225                op_type: "put".into(),
226                key_count: 5,
227            },
228        );
229        trace.record(
230            30,
231            1,
232            TraceEventKind::TimerFired {
233                timer_type: "election".into(),
234            },
235        );
236        trace.record(
237            35,
238            0,
239            TraceEventKind::MessageDropped {
240                from: 1,
241                to: 3,
242                reason: "partition".into(),
243            },
244        );
245        trace
246    }
247
248    #[test]
249    fn test_stats_message_counts() {
250        let trace = build_test_trace();
251        let stats = SimStats::from_trace(&trace);
252        assert_eq!(stats.messages_sent, 2);
253        assert_eq!(stats.messages_delivered, 2);
254        assert_eq!(stats.messages_dropped, 1);
255    }
256
257    #[test]
258    fn test_stats_event_counts() {
259        let trace = build_test_trace();
260        let stats = SimStats::from_trace(&trace);
261        assert_eq!(stats.state_transitions, 1);
262        assert_eq!(stats.faults_injected, 1);
263        assert_eq!(stats.faults_healed, 1);
264        assert_eq!(stats.storage_ops, 1);
265        assert_eq!(stats.timers_fired, 1);
266    }
267
268    #[test]
269    fn test_stats_latency_percentiles() {
270        let trace = build_test_trace();
271        let stats = SimStats::from_trace(&trace);
272        // Two latency samples: tick 2-1=1, tick 5-3=2
273        assert_eq!(stats.latency_sample_count(), 2);
274        assert!(stats.p50() >= 1 && stats.p50() <= 2);
275        assert_eq!(stats.p99(), 2);
276        assert!((stats.avg_latency() - 1.5).abs() < 0.01);
277    }
278
279    #[test]
280    fn test_stats_summary_contains_metrics() {
281        let trace = build_test_trace();
282        let stats = SimStats::from_trace(&trace);
283        let summary = stats.summary();
284        assert!(summary.contains("sent=2"));
285        assert!(summary.contains("delivered=2"));
286        assert!(summary.contains("dropped=1"));
287        assert!(summary.contains("State transitions: 1"));
288    }
289
290    #[test]
291    fn test_stats_json_valid() {
292        let trace = build_test_trace();
293        let stats = SimStats::from_trace(&trace);
294        let json = stats.to_json();
295        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
296        assert_eq!(parsed["messages_sent"], 2);
297        assert_eq!(parsed["messages_delivered"], 2);
298        assert_eq!(parsed["timers_fired"], 1);
299    }
300
301    #[test]
302    fn test_stats_empty_trace() {
303        let trace = SimTrace::new();
304        let stats = SimStats::from_trace(&trace);
305        assert_eq!(stats.messages_sent, 0);
306        assert_eq!(stats.p50(), 0);
307        assert_eq!(stats.avg_latency(), 0.0);
308    }
309}