Skip to main content

moonpool_sim/runner/
report.rs

1//! Simulation metrics and reporting.
2//!
3//! This module provides types for collecting and reporting simulation results.
4
5use std::collections::HashMap;
6use std::fmt;
7use std::time::Duration;
8
9use moonpool_explorer::AssertKind;
10
11use crate::SimulationResult;
12use crate::chaos::AssertionStats;
13
14/// Core metrics collected during a simulation run.
15#[derive(Debug, Clone, PartialEq)]
16pub struct SimulationMetrics {
17    /// Wall-clock time taken for the simulation
18    pub wall_time: Duration,
19    /// Simulated logical time elapsed
20    pub simulated_time: Duration,
21    /// Number of events processed
22    pub events_processed: u64,
23}
24
25impl Default for SimulationMetrics {
26    fn default() -> Self {
27        Self {
28            wall_time: Duration::ZERO,
29            simulated_time: Duration::ZERO,
30            events_processed: 0,
31        }
32    }
33}
34
35/// A captured bug recipe with its root seed for deterministic replay.
36#[derive(Debug, Clone, PartialEq)]
37pub struct BugRecipe {
38    /// The root seed that was active when this bug was discovered.
39    pub seed: u64,
40    /// Fork path: `(rng_call_count, child_seed)` pairs.
41    pub recipe: Vec<(u64, u64)>,
42}
43
44/// Report from fork-based exploration.
45#[derive(Debug, Clone)]
46pub struct ExplorationReport {
47    /// Total timelines explored across all forks.
48    pub total_timelines: u64,
49    /// Total fork points triggered.
50    pub fork_points: u64,
51    /// Number of bugs found (children exiting with code 42).
52    pub bugs_found: u64,
53    /// Bug recipes captured during exploration (one per seed that found bugs).
54    pub bug_recipes: Vec<BugRecipe>,
55    /// Remaining global energy after exploration.
56    pub energy_remaining: i64,
57    /// Energy in the reallocation pool.
58    pub realloc_pool_remaining: i64,
59    /// Number of bits set in the explored coverage map.
60    pub coverage_bits: u32,
61    /// Total number of bits in the coverage map (8192).
62    pub coverage_total: u32,
63    /// Total instrumented code edges (from LLVM sancov). 0 when sancov unavailable.
64    pub sancov_edges_total: usize,
65    /// Code edges covered across all timelines. 0 when sancov unavailable.
66    pub sancov_edges_covered: usize,
67    /// Whether the multi-seed loop stopped because convergence was detected.
68    pub converged: bool,
69    /// Timelines explored per seed (parallel to `seeds_used`).
70    pub per_seed_timelines: Vec<u64>,
71}
72
73/// Pass/fail/miss status for an assertion in the report.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75pub enum AssertionStatus {
76    /// Assertion contract violated (always-type failed, or unreachable reached).
77    Fail,
78    /// Coverage assertion never satisfied (sometimes/reachable never triggered).
79    Miss,
80    /// Assertion contract satisfied.
81    Pass,
82}
83
84/// Detailed information about a single assertion slot.
85#[derive(Debug, Clone)]
86pub struct AssertionDetail {
87    /// Human-readable assertion message.
88    pub msg: String,
89    /// The kind of assertion.
90    pub kind: AssertKind,
91    /// Number of times the assertion passed.
92    pub pass_count: u64,
93    /// Number of times the assertion failed.
94    pub fail_count: u64,
95    /// Best watermark value (for numeric assertions).
96    pub watermark: i64,
97    /// Frontier value (for BooleanSometimesAll).
98    pub frontier: u8,
99    /// Computed status based on kind and counts.
100    pub status: AssertionStatus,
101}
102
103/// Summary of one `assert_sometimes_each!` site (grouped by msg).
104#[derive(Debug, Clone)]
105pub struct BucketSiteSummary {
106    /// Assertion message identifying the site.
107    pub msg: String,
108    /// Number of unique identity-key buckets discovered.
109    pub buckets_discovered: usize,
110    /// Total hit count across all buckets.
111    pub total_hits: u64,
112}
113
114/// Comprehensive report of a simulation run with statistical analysis.
115#[derive(Debug, Clone)]
116pub struct SimulationReport {
117    /// Number of iterations executed
118    pub iterations: usize,
119    /// Number of successful runs
120    pub successful_runs: usize,
121    /// Number of failed runs
122    pub failed_runs: usize,
123    /// Aggregated metrics across all runs
124    pub metrics: SimulationMetrics,
125    /// Individual metrics for each iteration
126    pub individual_metrics: Vec<SimulationResult<SimulationMetrics>>,
127    /// Seeds used for each iteration
128    pub seeds_used: Vec<u64>,
129    /// failed seeds
130    pub seeds_failing: Vec<u64>,
131    /// Aggregated assertion results across all iterations
132    pub assertion_results: HashMap<String, AssertionStats>,
133    /// Always-type assertion violations (definite bugs).
134    pub assertion_violations: Vec<String>,
135    /// Coverage assertion violations (sometimes/reachable not satisfied).
136    pub coverage_violations: Vec<String>,
137    /// Exploration report (present when fork-based exploration was enabled).
138    pub exploration: Option<ExplorationReport>,
139    /// Per-assertion detailed breakdown from shared memory slots.
140    pub assertion_details: Vec<AssertionDetail>,
141    /// Per-site summaries of `assert_sometimes_each!` buckets.
142    pub bucket_summaries: Vec<BucketSiteSummary>,
143    /// True when `UntilConverged` hit its iteration cap without converging.
144    pub convergence_timeout: bool,
145}
146
147impl SimulationReport {
148    /// Whether the simulation run is considered successful.
149    ///
150    /// Returns `false` when any of the following hold:
151    /// - There are assertion violations (always-type failures).
152    /// - `UntilConverged` hit its iteration cap without converging.
153    pub fn is_success(&self) -> bool {
154        self.assertion_violations.is_empty() && !self.convergence_timeout
155    }
156
157    /// Calculate the success rate as a percentage.
158    pub fn success_rate(&self) -> f64 {
159        if self.iterations == 0 {
160            0.0
161        } else {
162            (self.successful_runs as f64 / self.iterations as f64) * 100.0
163        }
164    }
165
166    /// Get the average wall time per iteration.
167    pub fn average_wall_time(&self) -> Duration {
168        if self.successful_runs == 0 {
169            Duration::ZERO
170        } else {
171            self.metrics.wall_time / self.successful_runs as u32
172        }
173    }
174
175    /// Get the average simulated time per iteration.
176    pub fn average_simulated_time(&self) -> Duration {
177        if self.successful_runs == 0 {
178            Duration::ZERO
179        } else {
180            self.metrics.simulated_time / self.successful_runs as u32
181        }
182    }
183
184    /// Get the average number of events processed per iteration.
185    pub fn average_events_processed(&self) -> f64 {
186        if self.successful_runs == 0 {
187            0.0
188        } else {
189            self.metrics.events_processed as f64 / self.successful_runs as f64
190        }
191    }
192
193    /// Print the report to stderr with colors when the terminal supports it.
194    ///
195    /// Falls back to the plain `Display` output when stderr is not a TTY
196    /// or `NO_COLOR` is set.
197    pub fn eprint(&self) {
198        super::display::eprint_report(self);
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Display helpers
204// ---------------------------------------------------------------------------
205
206/// Format a number with comma separators (e.g., 123456 -> "123,456").
207fn fmt_num(n: u64) -> String {
208    let s = n.to_string();
209    let mut result = String::with_capacity(s.len() + s.len() / 3);
210    for (i, c) in s.chars().rev().enumerate() {
211        if i > 0 && i % 3 == 0 {
212            result.push(',');
213        }
214        result.push(c);
215    }
216    result.chars().rev().collect()
217}
218
219/// Format an `i64` with comma separators (handles negatives).
220fn fmt_i64(n: i64) -> String {
221    if n < 0 {
222        format!("-{}", fmt_num(n.unsigned_abs()))
223    } else {
224        fmt_num(n as u64)
225    }
226}
227
228/// Format a duration as a human-readable string.
229fn fmt_duration(d: Duration) -> String {
230    let total_ms = d.as_millis();
231    if total_ms < 1000 {
232        format!("{}ms", total_ms)
233    } else if total_ms < 60_000 {
234        format!("{:.2}s", d.as_secs_f64())
235    } else {
236        let mins = d.as_secs() / 60;
237        let secs = d.as_secs() % 60;
238        format!("{}m {:02}s", mins, secs)
239    }
240}
241
242/// Short human-readable label for an assertion kind.
243fn kind_label(kind: AssertKind) -> &'static str {
244    match kind {
245        AssertKind::Always => "always",
246        AssertKind::AlwaysOrUnreachable => "always?",
247        AssertKind::Sometimes => "sometimes",
248        AssertKind::Reachable => "reachable",
249        AssertKind::Unreachable => "unreachable",
250        AssertKind::NumericAlways => "num-always",
251        AssertKind::NumericSometimes => "numeric",
252        AssertKind::BooleanSometimesAll => "frontier",
253    }
254}
255
256/// Sort key for grouping assertion kinds in display.
257fn kind_sort_order(kind: AssertKind) -> u8 {
258    match kind {
259        AssertKind::Always => 0,
260        AssertKind::AlwaysOrUnreachable => 1,
261        AssertKind::Unreachable => 2,
262        AssertKind::NumericAlways => 3,
263        AssertKind::Sometimes => 4,
264        AssertKind::Reachable => 5,
265        AssertKind::NumericSometimes => 6,
266        AssertKind::BooleanSometimesAll => 7,
267    }
268}
269
270// ---------------------------------------------------------------------------
271// Display impl
272// ---------------------------------------------------------------------------
273
274impl fmt::Display for SimulationReport {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        // === Header ===
277        writeln!(f, "=== Simulation Report ===")?;
278        writeln!(
279            f,
280            "  Iterations: {}  |  Passed: {}  |  Failed: {}  |  Rate: {:.1}%",
281            self.iterations,
282            self.successful_runs,
283            self.failed_runs,
284            self.success_rate()
285        )?;
286        writeln!(f)?;
287
288        // === Timing ===
289        writeln!(
290            f,
291            "  Avg Wall Time:     {:<14}Total: {}",
292            fmt_duration(self.average_wall_time()),
293            fmt_duration(self.metrics.wall_time)
294        )?;
295        writeln!(
296            f,
297            "  Avg Sim Time:      {}",
298            fmt_duration(self.average_simulated_time())
299        )?;
300        writeln!(
301            f,
302            "  Avg Events:        {}",
303            fmt_num(self.average_events_processed() as u64)
304        )?;
305
306        // === Faulty Seeds ===
307        if !self.seeds_failing.is_empty() {
308            writeln!(f)?;
309            writeln!(f, "  Faulty seeds: {:?}", self.seeds_failing)?;
310        }
311
312        // === Exploration ===
313        if let Some(ref exp) = self.exploration {
314            writeln!(f)?;
315            writeln!(f, "--- Exploration ---")?;
316            writeln!(
317                f,
318                "  Timelines:    {:<18}Bugs found:     {}",
319                fmt_num(exp.total_timelines),
320                fmt_num(exp.bugs_found)
321            )?;
322            writeln!(
323                f,
324                "  Fork points:  {:<18}Coverage:       {} / {} bits ({:.1}%)",
325                fmt_num(exp.fork_points),
326                fmt_num(exp.coverage_bits as u64),
327                fmt_num(exp.coverage_total as u64),
328                if exp.coverage_total > 0 {
329                    (exp.coverage_bits as f64 / exp.coverage_total as f64) * 100.0
330                } else {
331                    0.0
332                }
333            )?;
334            if exp.sancov_edges_total > 0 {
335                writeln!(
336                    f,
337                    "  Sancov:       {} / {} edges ({:.1}%)",
338                    fmt_num(exp.sancov_edges_covered as u64),
339                    fmt_num(exp.sancov_edges_total as u64),
340                    (exp.sancov_edges_covered as f64 / exp.sancov_edges_total as f64) * 100.0
341                )?;
342            }
343            writeln!(
344                f,
345                "  Energy left:  {:<18}Realloc pool:   {}",
346                fmt_i64(exp.energy_remaining),
347                fmt_i64(exp.realloc_pool_remaining)
348            )?;
349            for br in &exp.bug_recipes {
350                writeln!(
351                    f,
352                    "  Bug recipe (seed={}): {}",
353                    br.seed,
354                    moonpool_explorer::format_timeline(&br.recipe)
355                )?;
356            }
357        }
358
359        // === Assertion Details ===
360        if !self.assertion_details.is_empty() {
361            writeln!(f)?;
362            writeln!(f, "--- Assertions ({}) ---", self.assertion_details.len())?;
363
364            let mut sorted: Vec<&AssertionDetail> = self.assertion_details.iter().collect();
365            sorted.sort_by(|a, b| {
366                kind_sort_order(a.kind)
367                    .cmp(&kind_sort_order(b.kind))
368                    .then(a.status.cmp(&b.status))
369                    .then(a.msg.cmp(&b.msg))
370            });
371
372            for detail in &sorted {
373                let status_tag = match detail.status {
374                    AssertionStatus::Pass => "PASS",
375                    AssertionStatus::Fail => "FAIL",
376                    AssertionStatus::Miss => "MISS",
377                };
378                let kind_tag = kind_label(detail.kind);
379                let quoted_msg = format!("\"{}\"", detail.msg);
380
381                match detail.kind {
382                    AssertKind::Sometimes | AssertKind::Reachable => {
383                        let total = detail.pass_count + detail.fail_count;
384                        let rate = if total > 0 {
385                            (detail.pass_count as f64 / total as f64) * 100.0
386                        } else {
387                            0.0
388                        };
389                        writeln!(
390                            f,
391                            "  {}  [{:<10}]  {:<34}  {} / {} ({:.1}%)",
392                            status_tag,
393                            kind_tag,
394                            quoted_msg,
395                            fmt_num(detail.pass_count),
396                            fmt_num(total),
397                            rate
398                        )?;
399                    }
400                    AssertKind::NumericSometimes | AssertKind::NumericAlways => {
401                        writeln!(
402                            f,
403                            "  {}  [{:<10}]  {:<34}  {} pass  {} fail  watermark: {}",
404                            status_tag,
405                            kind_tag,
406                            quoted_msg,
407                            fmt_num(detail.pass_count),
408                            fmt_num(detail.fail_count),
409                            detail.watermark
410                        )?;
411                    }
412                    AssertKind::BooleanSometimesAll => {
413                        writeln!(
414                            f,
415                            "  {}  [{:<10}]  {:<34}  {} calls  frontier: {}",
416                            status_tag,
417                            kind_tag,
418                            quoted_msg,
419                            fmt_num(detail.pass_count),
420                            detail.frontier
421                        )?;
422                    }
423                    _ => {
424                        // Always, AlwaysOrUnreachable, Unreachable
425                        writeln!(
426                            f,
427                            "  {}  [{:<10}]  {:<34}  {} pass  {} fail",
428                            status_tag,
429                            kind_tag,
430                            quoted_msg,
431                            fmt_num(detail.pass_count),
432                            fmt_num(detail.fail_count)
433                        )?;
434                    }
435                }
436            }
437        }
438
439        // === Assertion Violations ===
440        if !self.assertion_violations.is_empty() {
441            writeln!(f)?;
442            writeln!(f, "--- Assertion Violations ---")?;
443            for v in &self.assertion_violations {
444                writeln!(f, "  - {}", v)?;
445            }
446        }
447
448        // === Coverage Gaps ===
449        if !self.coverage_violations.is_empty() {
450            writeln!(f)?;
451            writeln!(f, "--- Coverage Gaps ---")?;
452            for v in &self.coverage_violations {
453                writeln!(f, "  - {}", v)?;
454            }
455        }
456
457        // === Buckets ===
458        if !self.bucket_summaries.is_empty() {
459            let total_buckets: usize = self
460                .bucket_summaries
461                .iter()
462                .map(|s| s.buckets_discovered)
463                .sum();
464            writeln!(f)?;
465            writeln!(
466                f,
467                "--- Buckets ({} across {} sites) ---",
468                total_buckets,
469                self.bucket_summaries.len()
470            )?;
471            for bs in &self.bucket_summaries {
472                writeln!(
473                    f,
474                    "  {:<34}  {:>3} buckets  {:>8} hits",
475                    format!("\"{}\"", bs.msg),
476                    bs.buckets_discovered,
477                    fmt_num(bs.total_hits)
478                )?;
479            }
480        }
481
482        // === Convergence Timeout ===
483        if self.convergence_timeout {
484            writeln!(f)?;
485            writeln!(f, "--- Convergence FAILED ---")?;
486            writeln!(f, "  UntilConverged hit iteration cap without converging.")?;
487        }
488
489        // === Per-Seed Metrics ===
490        if self.seeds_used.len() > 1 {
491            writeln!(f)?;
492            writeln!(f, "--- Seeds ---")?;
493            let per_seed_tl = self.exploration.as_ref().map(|e| &e.per_seed_timelines);
494            for (i, seed) in self.seeds_used.iter().enumerate() {
495                if let Some(Ok(m)) = self.individual_metrics.get(i) {
496                    let tl_suffix = per_seed_tl
497                        .and_then(|v| v.get(i))
498                        .map(|t| format!("  timelines={}", fmt_num(*t)))
499                        .unwrap_or_default();
500                    writeln!(
501                        f,
502                        "  #{:<3}  seed={:<14}  wall={:<10}  sim={:<10}  events={}{}",
503                        i + 1,
504                        seed,
505                        fmt_duration(m.wall_time),
506                        fmt_duration(m.simulated_time),
507                        fmt_num(m.events_processed),
508                        tl_suffix,
509                    )?;
510                } else if let Some(Err(_)) = self.individual_metrics.get(i) {
511                    writeln!(f, "  #{:<3}  seed={:<14}  FAILED", i + 1, seed)?;
512                }
513            }
514        }
515
516        writeln!(f)?;
517        Ok(())
518    }
519}