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
147/// Errors from [`SimulationReport::check`].
148#[derive(Debug, thiserror::Error)]
149pub enum ReportCheckError {
150    /// One or more seeds produced panics or assertion failures.
151    #[error("{name}: {count} failing seeds: {seeds:?}")]
152    FailingSeeds {
153        /// Name of the scenario that was checked.
154        name: String,
155        /// Number of failing seeds.
156        count: usize,
157        /// The failing seed values.
158        seeds: Vec<u64>,
159    },
160    /// One or more always-type assertion contracts were violated.
161    #[error("{name}: assertion violations:\n{violations}")]
162    AssertionViolations {
163        /// Name of the scenario that was checked.
164        name: String,
165        /// Formatted violation list.
166        violations: String,
167    },
168}
169
170impl SimulationReport {
171    /// Check whether the report contains failures or assertion violations.
172    ///
173    /// Returns `Ok(())` when the report is clean, or the first error found.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`ReportCheckError::FailingSeeds`] when any seeds failed,
178    /// or [`ReportCheckError::AssertionViolations`] when assertion contracts
179    /// were violated.
180    pub fn check(&self, name: &str) -> Result<(), ReportCheckError> {
181        if !self.seeds_failing.is_empty() {
182            return Err(ReportCheckError::FailingSeeds {
183                name: name.to_string(),
184                count: self.seeds_failing.len(),
185                seeds: self.seeds_failing.clone(),
186            });
187        }
188        if !self.assertion_violations.is_empty() {
189            return Err(ReportCheckError::AssertionViolations {
190                name: name.to_string(),
191                violations: self
192                    .assertion_violations
193                    .iter()
194                    .map(|v| format!("  - {v}"))
195                    .collect::<Vec<_>>()
196                    .join("\n"),
197            });
198        }
199        Ok(())
200    }
201
202    /// Whether the simulation run is considered successful.
203    ///
204    /// Returns `false` when any of the following hold:
205    /// - There are assertion violations (always-type failures).
206    /// - `UntilConverged` hit its iteration cap without converging.
207    pub fn is_success(&self) -> bool {
208        self.assertion_violations.is_empty() && !self.convergence_timeout
209    }
210
211    /// Calculate the success rate as a percentage.
212    pub fn success_rate(&self) -> f64 {
213        if self.iterations == 0 {
214            0.0
215        } else {
216            (self.successful_runs as f64 / self.iterations as f64) * 100.0
217        }
218    }
219
220    /// Get the average wall time per iteration.
221    pub fn average_wall_time(&self) -> Duration {
222        if self.successful_runs == 0 {
223            Duration::ZERO
224        } else {
225            self.metrics.wall_time / self.successful_runs as u32
226        }
227    }
228
229    /// Get the average simulated time per iteration.
230    pub fn average_simulated_time(&self) -> Duration {
231        if self.successful_runs == 0 {
232            Duration::ZERO
233        } else {
234            self.metrics.simulated_time / self.successful_runs as u32
235        }
236    }
237
238    /// Get the average number of events processed per iteration.
239    pub fn average_events_processed(&self) -> f64 {
240        if self.successful_runs == 0 {
241            0.0
242        } else {
243            self.metrics.events_processed as f64 / self.successful_runs as f64
244        }
245    }
246
247    /// Print the report to stderr with colors when the terminal supports it.
248    ///
249    /// Falls back to the plain `Display` output when stderr is not a TTY
250    /// or `NO_COLOR` is set.
251    pub fn eprint(&self) {
252        super::display::eprint_report(self);
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Display helpers
258// ---------------------------------------------------------------------------
259
260/// Format a number with comma separators (e.g., 123456 -> "123,456").
261fn fmt_num(n: u64) -> String {
262    let s = n.to_string();
263    let mut result = String::with_capacity(s.len() + s.len() / 3);
264    for (i, c) in s.chars().rev().enumerate() {
265        if i > 0 && i % 3 == 0 {
266            result.push(',');
267        }
268        result.push(c);
269    }
270    result.chars().rev().collect()
271}
272
273/// Format an `i64` with comma separators (handles negatives).
274fn fmt_i64(n: i64) -> String {
275    if n < 0 {
276        format!("-{}", fmt_num(n.unsigned_abs()))
277    } else {
278        fmt_num(n as u64)
279    }
280}
281
282/// Format a duration as a human-readable string.
283fn fmt_duration(d: Duration) -> String {
284    let total_ms = d.as_millis();
285    if total_ms < 1000 {
286        format!("{}ms", total_ms)
287    } else if total_ms < 60_000 {
288        format!("{:.2}s", d.as_secs_f64())
289    } else {
290        let mins = d.as_secs() / 60;
291        let secs = d.as_secs() % 60;
292        format!("{}m {:02}s", mins, secs)
293    }
294}
295
296/// Short human-readable label for an assertion kind.
297fn kind_label(kind: AssertKind) -> &'static str {
298    match kind {
299        AssertKind::Always => "always",
300        AssertKind::AlwaysOrUnreachable => "always?",
301        AssertKind::Sometimes => "sometimes",
302        AssertKind::Reachable => "reachable",
303        AssertKind::Unreachable => "unreachable",
304        AssertKind::NumericAlways => "num-always",
305        AssertKind::NumericSometimes => "numeric",
306        AssertKind::BooleanSometimesAll => "frontier",
307    }
308}
309
310/// Sort key for grouping assertion kinds in display.
311fn kind_sort_order(kind: AssertKind) -> u8 {
312    match kind {
313        AssertKind::Always => 0,
314        AssertKind::AlwaysOrUnreachable => 1,
315        AssertKind::Unreachable => 2,
316        AssertKind::NumericAlways => 3,
317        AssertKind::Sometimes => 4,
318        AssertKind::Reachable => 5,
319        AssertKind::NumericSometimes => 6,
320        AssertKind::BooleanSometimesAll => 7,
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Display impl
326// ---------------------------------------------------------------------------
327
328impl fmt::Display for SimulationReport {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        // === Header ===
331        writeln!(f, "=== Simulation Report ===")?;
332        writeln!(
333            f,
334            "  Iterations: {}  |  Passed: {}  |  Failed: {}  |  Rate: {:.1}%",
335            self.iterations,
336            self.successful_runs,
337            self.failed_runs,
338            self.success_rate()
339        )?;
340        writeln!(f)?;
341
342        // === Timing ===
343        writeln!(
344            f,
345            "  Avg Wall Time:     {:<14}Total: {}",
346            fmt_duration(self.average_wall_time()),
347            fmt_duration(self.metrics.wall_time)
348        )?;
349        writeln!(
350            f,
351            "  Avg Sim Time:      {}",
352            fmt_duration(self.average_simulated_time())
353        )?;
354        writeln!(
355            f,
356            "  Avg Events:        {}",
357            fmt_num(self.average_events_processed() as u64)
358        )?;
359
360        // === Faulty Seeds ===
361        if !self.seeds_failing.is_empty() {
362            writeln!(f)?;
363            writeln!(f, "  Faulty seeds: {:?}", self.seeds_failing)?;
364        }
365
366        // === Exploration ===
367        if let Some(ref exp) = self.exploration {
368            writeln!(f)?;
369            writeln!(f, "--- Exploration ---")?;
370            writeln!(
371                f,
372                "  Timelines:    {:<18}Bugs found:     {}",
373                fmt_num(exp.total_timelines),
374                fmt_num(exp.bugs_found)
375            )?;
376            writeln!(
377                f,
378                "  Fork points:  {:<18}Coverage:       {} / {} bits ({:.1}%)",
379                fmt_num(exp.fork_points),
380                fmt_num(exp.coverage_bits as u64),
381                fmt_num(exp.coverage_total as u64),
382                if exp.coverage_total > 0 {
383                    (exp.coverage_bits as f64 / exp.coverage_total as f64) * 100.0
384                } else {
385                    0.0
386                }
387            )?;
388            if exp.sancov_edges_total > 0 {
389                writeln!(
390                    f,
391                    "  Sancov:       {} / {} edges ({:.1}%)",
392                    fmt_num(exp.sancov_edges_covered as u64),
393                    fmt_num(exp.sancov_edges_total as u64),
394                    (exp.sancov_edges_covered as f64 / exp.sancov_edges_total as f64) * 100.0
395                )?;
396            }
397            writeln!(
398                f,
399                "  Energy left:  {:<18}Realloc pool:   {}",
400                fmt_i64(exp.energy_remaining),
401                fmt_i64(exp.realloc_pool_remaining)
402            )?;
403            for br in &exp.bug_recipes {
404                writeln!(
405                    f,
406                    "  Bug recipe (seed={}): {}",
407                    br.seed,
408                    moonpool_explorer::format_timeline(&br.recipe)
409                )?;
410            }
411        }
412
413        // === Assertion Details ===
414        if !self.assertion_details.is_empty() {
415            writeln!(f)?;
416            writeln!(f, "--- Assertions ({}) ---", self.assertion_details.len())?;
417
418            let mut sorted: Vec<&AssertionDetail> = self.assertion_details.iter().collect();
419            sorted.sort_by(|a, b| {
420                kind_sort_order(a.kind)
421                    .cmp(&kind_sort_order(b.kind))
422                    .then(a.status.cmp(&b.status))
423                    .then(a.msg.cmp(&b.msg))
424            });
425
426            for detail in &sorted {
427                let status_tag = match detail.status {
428                    AssertionStatus::Pass => "PASS",
429                    AssertionStatus::Fail => "FAIL",
430                    AssertionStatus::Miss => "MISS",
431                };
432                let kind_tag = kind_label(detail.kind);
433                let quoted_msg = format!("\"{}\"", detail.msg);
434
435                match detail.kind {
436                    AssertKind::Sometimes | AssertKind::Reachable => {
437                        let total = detail.pass_count + detail.fail_count;
438                        let rate = if total > 0 {
439                            (detail.pass_count as f64 / total as f64) * 100.0
440                        } else {
441                            0.0
442                        };
443                        writeln!(
444                            f,
445                            "  {}  [{:<10}]  {:<34}  {} / {} ({:.1}%)",
446                            status_tag,
447                            kind_tag,
448                            quoted_msg,
449                            fmt_num(detail.pass_count),
450                            fmt_num(total),
451                            rate
452                        )?;
453                    }
454                    AssertKind::NumericSometimes | AssertKind::NumericAlways => {
455                        writeln!(
456                            f,
457                            "  {}  [{:<10}]  {:<34}  {} pass  {} fail  watermark: {}",
458                            status_tag,
459                            kind_tag,
460                            quoted_msg,
461                            fmt_num(detail.pass_count),
462                            fmt_num(detail.fail_count),
463                            detail.watermark
464                        )?;
465                    }
466                    AssertKind::BooleanSometimesAll => {
467                        writeln!(
468                            f,
469                            "  {}  [{:<10}]  {:<34}  {} calls  frontier: {}",
470                            status_tag,
471                            kind_tag,
472                            quoted_msg,
473                            fmt_num(detail.pass_count),
474                            detail.frontier
475                        )?;
476                    }
477                    _ => {
478                        // Always, AlwaysOrUnreachable, Unreachable
479                        writeln!(
480                            f,
481                            "  {}  [{:<10}]  {:<34}  {} pass  {} fail",
482                            status_tag,
483                            kind_tag,
484                            quoted_msg,
485                            fmt_num(detail.pass_count),
486                            fmt_num(detail.fail_count)
487                        )?;
488                    }
489                }
490            }
491        }
492
493        // === Assertion Violations ===
494        if !self.assertion_violations.is_empty() {
495            writeln!(f)?;
496            writeln!(f, "--- Assertion Violations ---")?;
497            for v in &self.assertion_violations {
498                writeln!(f, "  - {}", v)?;
499            }
500        }
501
502        // === Coverage Gaps ===
503        if !self.coverage_violations.is_empty() {
504            writeln!(f)?;
505            writeln!(f, "--- Coverage Gaps ---")?;
506            for v in &self.coverage_violations {
507                writeln!(f, "  - {}", v)?;
508            }
509        }
510
511        // === Buckets ===
512        if !self.bucket_summaries.is_empty() {
513            let total_buckets: usize = self
514                .bucket_summaries
515                .iter()
516                .map(|s| s.buckets_discovered)
517                .sum();
518            writeln!(f)?;
519            writeln!(
520                f,
521                "--- Buckets ({} across {} sites) ---",
522                total_buckets,
523                self.bucket_summaries.len()
524            )?;
525            for bs in &self.bucket_summaries {
526                writeln!(
527                    f,
528                    "  {:<34}  {:>3} buckets  {:>8} hits",
529                    format!("\"{}\"", bs.msg),
530                    bs.buckets_discovered,
531                    fmt_num(bs.total_hits)
532                )?;
533            }
534        }
535
536        // === Convergence Timeout ===
537        if self.convergence_timeout {
538            writeln!(f)?;
539            writeln!(f, "--- Convergence FAILED ---")?;
540            writeln!(f, "  UntilConverged hit iteration cap without converging.")?;
541        }
542
543        // === Per-Seed Metrics ===
544        if self.seeds_used.len() > 1 {
545            writeln!(f)?;
546            writeln!(f, "--- Seeds ---")?;
547            let per_seed_tl = self.exploration.as_ref().map(|e| &e.per_seed_timelines);
548            for (i, seed) in self.seeds_used.iter().enumerate() {
549                if let Some(Ok(m)) = self.individual_metrics.get(i) {
550                    let tl_suffix = per_seed_tl
551                        .and_then(|v| v.get(i))
552                        .map(|t| format!("  timelines={}", fmt_num(*t)))
553                        .unwrap_or_default();
554                    writeln!(
555                        f,
556                        "  #{:<3}  seed={:<14}  wall={:<10}  sim={:<10}  events={}{}",
557                        i + 1,
558                        seed,
559                        fmt_duration(m.wall_time),
560                        fmt_duration(m.simulated_time),
561                        fmt_num(m.events_processed),
562                        tl_suffix,
563                    )?;
564                } else if let Some(Err(_)) = self.individual_metrics.get(i) {
565                    writeln!(f, "  #{:<3}  seed={:<14}  FAILED", i + 1, seed)?;
566                }
567            }
568        }
569
570        writeln!(f)?;
571        Ok(())
572    }
573}