Skip to main content

moonpool_sim/runner/
builder.rs

1//! Simulation builder pattern for configuring and running experiments.
2//!
3//! This module provides the main SimulationBuilder type for setting up
4//! and executing simulation experiments.
5
6use std::collections::HashMap;
7use std::ops::Range;
8use std::time::{Duration, Instant};
9use tracing::instrument;
10
11use crate::chaos::invariant_trait::Invariant;
12use crate::runner::fault_injector::{FaultInjector, PhaseConfig};
13use crate::runner::workload::Workload;
14
15use super::orchestrator::{IterationManager, MetricsCollector, WorkloadOrchestrator};
16use super::report::{SimulationMetrics, SimulationReport};
17
18/// Configuration for how many iterations a simulation should run.
19///
20/// Provides flexible control over simulation execution duration and completion criteria.
21#[derive(Debug, Clone)]
22pub enum IterationControl {
23    /// Run a fixed number of iterations with specific seeds
24    FixedCount(usize),
25    /// Run for a specific duration of wall-clock time
26    TimeLimit(Duration),
27}
28
29/// How many instances of a workload to spawn per iteration.
30///
31/// Use `Fixed` for deterministic topologies or `Random` for chaos testing
32/// with varying cluster sizes.
33///
34/// # Examples
35///
36/// ```ignore
37/// // Always 3 replicas
38/// WorkloadCount::Fixed(3)
39///
40/// // 1 to 5 replicas, randomized per iteration
41/// WorkloadCount::Random(1..6)
42/// ```
43#[derive(Debug, Clone)]
44pub enum WorkloadCount {
45    /// Spawn exactly N instances every iteration.
46    Fixed(usize),
47    /// Spawn a random number of instances in `[start..end)` per iteration,
48    /// using the simulation RNG (deterministic per seed).
49    Random(Range<usize>),
50}
51
52impl WorkloadCount {
53    /// Resolve the count for the current iteration.
54    /// For `Random`, uses the sim RNG which must already be seeded.
55    fn resolve(&self) -> usize {
56        match self {
57            WorkloadCount::Fixed(n) => *n,
58            WorkloadCount::Random(range) => crate::sim::sim_random_range(range.clone()),
59        }
60    }
61}
62
63/// Internal storage for workload entries in the builder.
64enum WorkloadEntry {
65    /// Single instance, reused across iterations (from `.workload()`).
66    Instance(Option<Box<dyn Workload>>),
67    /// Factory-based, fresh instances per iteration (from `.workloads()`).
68    Factory {
69        count: WorkloadCount,
70        factory: Box<dyn Fn(usize) -> Box<dyn Workload>>,
71    },
72}
73
74/// Builder pattern for configuring and running simulation experiments.
75pub struct SimulationBuilder {
76    iteration_control: IterationControl,
77    entries: Vec<WorkloadEntry>,
78    seeds: Vec<u64>,
79    use_random_config: bool,
80    invariants: Vec<Box<dyn Invariant>>,
81    fault_injectors: Vec<Box<dyn FaultInjector>>,
82    phase_config: Option<PhaseConfig>,
83    exploration_config: Option<moonpool_explorer::ExplorationConfig>,
84    replay_recipe: Option<super::report::BugRecipe>,
85}
86
87impl Default for SimulationBuilder {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl SimulationBuilder {
94    /// Create a new empty simulation builder.
95    pub fn new() -> Self {
96        Self {
97            iteration_control: IterationControl::FixedCount(1),
98            entries: Vec::new(),
99            seeds: Vec::new(),
100            use_random_config: false,
101            invariants: Vec::new(),
102            fault_injectors: Vec::new(),
103            phase_config: None,
104            exploration_config: None,
105            replay_recipe: None,
106        }
107    }
108
109    /// Add a single workload instance to the simulation.
110    ///
111    /// The instance is reused across iterations (the `run()` method is called
112    /// each iteration on the same struct).
113    pub fn workload(mut self, w: impl Workload) -> Self {
114        self.entries
115            .push(WorkloadEntry::Instance(Some(Box::new(w))));
116        self
117    }
118
119    /// Add multiple workload instances from a factory.
120    ///
121    /// The factory receives an instance index (0-based) and must return a fresh
122    /// workload. Instances are created each iteration and dropped afterward.
123    ///
124    /// The workload is responsible for its own `name()` — use the index to
125    /// produce unique names when count > 1 (e.g., `format!("client-{i}")`).
126    ///
127    /// # Examples
128    ///
129    /// ```ignore
130    /// // 3 fixed replicas
131    /// builder.workloads(WorkloadCount::Fixed(3), |i| Box::new(ReplicaWorkload::new(i)))
132    ///
133    /// // 1–5 random clients
134    /// builder.workloads(WorkloadCount::Random(1..6), |i| Box::new(ClientWorkload::new(i)))
135    /// ```
136    pub fn workloads(
137        mut self,
138        count: WorkloadCount,
139        factory: impl Fn(usize) -> Box<dyn Workload> + 'static,
140    ) -> Self {
141        self.entries.push(WorkloadEntry::Factory {
142            count,
143            factory: Box::new(factory),
144        });
145        self
146    }
147
148    /// Add an invariant to be checked after every simulation event.
149    pub fn invariant(mut self, i: impl Invariant) -> Self {
150        self.invariants.push(Box::new(i));
151        self
152    }
153
154    /// Add a closure-based invariant.
155    pub fn invariant_fn(
156        mut self,
157        name: impl Into<String>,
158        f: impl Fn(&crate::chaos::StateHandle, u64) + 'static,
159    ) -> Self {
160        self.invariants.push(crate::chaos::invariant_fn(name, f));
161        self
162    }
163
164    /// Add a fault injector to run during the chaos phase.
165    pub fn fault(mut self, f: impl FaultInjector) -> Self {
166        self.fault_injectors.push(Box::new(f));
167        self
168    }
169
170    /// Set two-phase chaos/recovery configuration.
171    pub fn phases(mut self, config: PhaseConfig) -> Self {
172        self.phase_config = Some(config);
173        self
174    }
175
176    /// Set the number of iterations to run.
177    pub fn set_iterations(mut self, iterations: usize) -> Self {
178        self.iteration_control = IterationControl::FixedCount(iterations);
179        self
180    }
181
182    /// Set the iteration control strategy.
183    pub fn set_iteration_control(mut self, control: IterationControl) -> Self {
184        self.iteration_control = control;
185        self
186    }
187
188    /// Run for a specific wall-clock time duration.
189    pub fn set_time_limit(mut self, duration: Duration) -> Self {
190        self.iteration_control = IterationControl::TimeLimit(duration);
191        self
192    }
193
194    /// Set specific seeds for deterministic debugging and regression testing.
195    pub fn set_debug_seeds(mut self, seeds: Vec<u64>) -> Self {
196        self.seeds = seeds;
197        self
198    }
199
200    /// Enable randomized network configuration for chaos testing.
201    pub fn random_network(mut self) -> Self {
202        self.use_random_config = true;
203        self
204    }
205
206    /// Enable fork-based multiverse exploration.
207    ///
208    /// When enabled, the simulation will fork child processes at assertion
209    /// discovery points to explore alternate timelines with different seeds.
210    pub fn enable_exploration(mut self, config: moonpool_explorer::ExplorationConfig) -> Self {
211        self.exploration_config = Some(config);
212        self
213    }
214
215    /// Set a bug recipe for deterministic replay.
216    ///
217    /// The builder applies the recipe's RNG breakpoints after its own
218    /// initialization, ensuring they survive internal resets.
219    pub fn replay_recipe(mut self, recipe: super::report::BugRecipe) -> Self {
220        self.replay_recipe = Some(recipe);
221        self
222    }
223
224    /// Resolve all entries into a flat workload list for one iteration.
225    ///
226    /// Returns `(workloads, return_map)` where `return_map[i] = Some(entry_idx)`
227    /// means `workloads[i]` should be returned to `entries[entry_idx]` after the iteration.
228    fn resolve_entries(&mut self) -> (Vec<Box<dyn Workload>>, Vec<Option<usize>>) {
229        let mut workloads = Vec::new();
230        let mut return_map = Vec::new();
231
232        for (entry_idx, entry) in self.entries.iter_mut().enumerate() {
233            match entry {
234                WorkloadEntry::Instance(opt) => {
235                    if let Some(w) = opt.take() {
236                        return_map.push(Some(entry_idx));
237                        workloads.push(w);
238                    }
239                }
240                WorkloadEntry::Factory { count, factory } => {
241                    let n = count.resolve();
242                    for i in 0..n {
243                        return_map.push(None);
244                        workloads.push(factory(i));
245                    }
246                }
247            }
248        }
249
250        (workloads, return_map)
251    }
252
253    /// Return instance-based workloads to their entry slots after an iteration.
254    fn return_entries(
255        &mut self,
256        workloads: Vec<Box<dyn Workload>>,
257        return_map: Vec<Option<usize>>,
258    ) {
259        for (w, slot) in workloads.into_iter().zip(return_map) {
260            if let Some(entry_idx) = slot
261                && let WorkloadEntry::Instance(opt) = &mut self.entries[entry_idx]
262            {
263                *opt = Some(w);
264            }
265            // Factory-created workloads are dropped
266        }
267    }
268
269    #[instrument(skip_all)]
270    /// Run the simulation and generate a report.
271    pub async fn run(mut self) -> SimulationReport {
272        if self.entries.is_empty() {
273            return SimulationReport {
274                iterations: 0,
275                successful_runs: 0,
276                failed_runs: 0,
277                metrics: SimulationMetrics::default(),
278                individual_metrics: Vec::new(),
279                seeds_used: Vec::new(),
280                seeds_failing: Vec::new(),
281                assertion_results: HashMap::new(),
282                assertion_violations: Vec::new(),
283                coverage_violations: Vec::new(),
284                exploration: None,
285                assertion_details: Vec::new(),
286                bucket_summaries: Vec::new(),
287            };
288        }
289
290        // Initialize iteration state
291        let mut iteration_manager =
292            IterationManager::new(self.iteration_control.clone(), self.seeds.clone());
293        let mut metrics_collector = MetricsCollector::new();
294
295        // Accumulators for multi-seed exploration stats
296        let mut total_exploration_timelines: u64 = 0;
297        let mut total_exploration_fork_points: u64 = 0;
298        let mut total_exploration_bugs: u64 = 0;
299        let mut bug_recipes: Vec<super::report::BugRecipe> = Vec::new();
300
301        // Initialize assertion table (unconditional — works even without exploration)
302        if let Err(e) = moonpool_explorer::init_assertions() {
303            tracing::error!("Failed to initialize assertion table: {}", e);
304        }
305
306        // Initialize exploration if configured
307        if let Some(ref config) = self.exploration_config {
308            moonpool_explorer::set_rng_hooks(crate::sim::get_rng_call_count, |seed| {
309                crate::sim::set_sim_seed(seed);
310                crate::sim::reset_rng_call_count();
311            });
312            if let Err(e) = moonpool_explorer::init(config.clone()) {
313                tracing::error!("Failed to initialize exploration: {}", e);
314            }
315        }
316
317        while iteration_manager.should_continue() {
318            let seed = iteration_manager.next_iteration();
319            let iteration_count = iteration_manager.current_iteration();
320
321            // Preserve assertion data across iterations so the final report
322            // reflects all seeds, not just the last one.  For exploration runs,
323            // prepare_next_seed() also does a selective reset of coverage state.
324            if iteration_count > 1 {
325                if let Some(ref config) = self.exploration_config {
326                    moonpool_explorer::prepare_next_seed(config.global_energy);
327                }
328                crate::chaos::assertions::skip_next_assertion_reset();
329            }
330
331            // Prepare clean state for this iteration
332            crate::sim::reset_sim_rng();
333            crate::sim::set_sim_seed(seed);
334            crate::chaos::reset_always_violations();
335
336            // Initialize buggify system for this iteration
337            // Use moderate probabilities: 50% activation rate, 25% firing rate
338            crate::chaos::buggify_init(0.5, 0.25);
339
340            // Resolve workload entries into concrete instances for this iteration
341            // (WorkloadCount::Random uses the sim RNG, already seeded above)
342            let (workloads, return_map) = self.resolve_entries();
343
344            // Compute workload name/IP pairs from resolved workloads
345            let workload_info: Vec<(String, String)> = workloads
346                .iter()
347                .enumerate()
348                .map(|(i, w)| (w.name().to_string(), format!("10.0.0.{}", i + 1)))
349                .collect();
350
351            // Create fresh NetworkConfiguration for this iteration
352            let network_config = if self.use_random_config {
353                crate::NetworkConfiguration::random_for_seed()
354            } else {
355                crate::NetworkConfiguration::default()
356            };
357
358            // Create shared SimWorld for this iteration using fresh network config
359            let sim = crate::sim::SimWorld::new_with_network_config_and_seed(network_config, seed);
360
361            // Apply replay breakpoints after SimWorld creation (which resets RNG state)
362            if let Some(ref br) = self.replay_recipe {
363                crate::sim::set_rng_breakpoints(br.recipe.clone());
364            }
365
366            let start_time = Instant::now();
367
368            // Move fault injectors to orchestrator, get them back after
369            let fault_injectors = std::mem::take(&mut self.fault_injectors);
370
371            // Execute workloads using orchestrator
372            let orchestration_result = WorkloadOrchestrator::orchestrate_workloads(
373                workloads,
374                fault_injectors,
375                &self.invariants,
376                &workload_info,
377                seed,
378                sim,
379                self.phase_config.as_ref(),
380                iteration_count,
381            )
382            .await;
383
384            match orchestration_result {
385                Ok((returned_workloads, returned_injectors, all_results, sim_metrics)) => {
386                    // Return Instance workloads to their entry slots
387                    self.return_entries(returned_workloads, return_map);
388                    self.fault_injectors = returned_injectors;
389
390                    let wall_time = start_time.elapsed();
391                    let has_violations = crate::chaos::has_always_violations();
392
393                    metrics_collector.record_iteration(
394                        seed,
395                        wall_time,
396                        &all_results,
397                        has_violations,
398                        sim_metrics,
399                    );
400                }
401                Err((faulty_seeds_from_deadlock, failed_count)) => {
402                    // Handle deadlock case - merge with existing state and return early
403                    metrics_collector.add_faulty_seeds(faulty_seeds_from_deadlock);
404                    metrics_collector.add_failed_runs(failed_count);
405
406                    // Create early exit report
407                    let assertion_results = crate::chaos::get_assertion_results();
408                    let (assertion_violations, coverage_violations) =
409                        crate::chaos::validate_assertion_contracts();
410                    crate::chaos::buggify_reset();
411
412                    return metrics_collector.generate_report(
413                        iteration_count,
414                        iteration_manager.seeds_used().to_vec(),
415                        assertion_results,
416                        assertion_violations,
417                        coverage_violations,
418                        None,
419                        Vec::new(),
420                        Vec::new(),
421                    );
422                }
423            }
424
425            // Accumulate exploration stats across seeds (before reset)
426            if self.exploration_config.is_some() {
427                if let Some(stats) = moonpool_explorer::get_exploration_stats() {
428                    total_exploration_timelines += stats.total_timelines;
429                    total_exploration_fork_points += stats.fork_points;
430                    total_exploration_bugs += stats.bug_found;
431                }
432                if let Some(recipe) = moonpool_explorer::get_bug_recipe() {
433                    bug_recipes.push(super::report::BugRecipe { seed, recipe });
434                }
435            }
436
437            // Reset buggify state after each iteration to ensure clean state
438            crate::chaos::buggify_reset();
439        }
440
441        // End of main iteration loop
442        //
443        // Data collection: read ALL shared memory BEFORE any cleanup.
444        // cleanup() calls cleanup_assertions() which frees the assertion
445        // table and each-bucket table.
446
447        // 1. Read exploration-specific data (freed by cleanup)
448        let exploration_report = if self.exploration_config.is_some() {
449            let final_stats = moonpool_explorer::get_exploration_stats();
450            // The per-iteration capture above should have caught all recipes.
451            // No fallback needed since we capture after every iteration.
452            let coverage_bits = moonpool_explorer::explored_map_bits_set().unwrap_or(0);
453
454            Some(super::report::ExplorationReport {
455                total_timelines: total_exploration_timelines,
456                fork_points: total_exploration_fork_points,
457                bugs_found: total_exploration_bugs,
458                bug_recipes,
459                energy_remaining: final_stats.as_ref().map(|s| s.global_energy).unwrap_or(0),
460                realloc_pool_remaining: final_stats
461                    .as_ref()
462                    .map(|s| s.realloc_pool_remaining)
463                    .unwrap_or(0),
464                coverage_bits,
465                coverage_total: (moonpool_explorer::coverage::COVERAGE_MAP_SIZE * 8) as u32,
466            })
467        } else {
468            None
469        };
470
471        // 2. Read assertion + bucket data (freed by cleanup/cleanup_assertions)
472        let assertion_results = crate::chaos::get_assertion_results();
473        let (assertion_violations, coverage_violations) =
474            crate::chaos::validate_assertion_contracts();
475        let raw_assertion_slots = moonpool_explorer::assertion_read_all();
476        let raw_each_buckets = moonpool_explorer::each_bucket_read_all();
477
478        // 3. Now safe to free all shared memory
479        if self.exploration_config.is_some() {
480            moonpool_explorer::cleanup();
481        } else {
482            moonpool_explorer::cleanup_assertions();
483        }
484
485        // 4. Build rich assertion details from raw slot snapshots
486        let assertion_details = build_assertion_details(&raw_assertion_slots);
487
488        // 5. Build bucket summaries by grouping EachBuckets by site
489        let bucket_summaries = build_bucket_summaries(&raw_each_buckets);
490
491        // Print exploration summary (always visible, no tracing subscriber needed)
492        if let Some(ref exp) = exploration_report {
493            eprintln!(
494                "\n--- Exploration ---\n  timelines: {}  |  fork points: {}  |  bugs: {}  |  energy left: {}  |  coverage: {}/{} ({:.1}%)",
495                exp.total_timelines,
496                exp.fork_points,
497                exp.bugs_found,
498                exp.energy_remaining,
499                exp.coverage_bits,
500                exp.coverage_total,
501                if exp.coverage_total > 0 {
502                    (exp.coverage_bits as f64 / exp.coverage_total as f64) * 100.0
503                } else {
504                    0.0
505                }
506            );
507            for br in &exp.bug_recipes {
508                eprintln!(
509                    "  bug recipe: {}",
510                    moonpool_explorer::format_timeline(&br.recipe)
511                );
512            }
513        }
514
515        // Log summary of all seeds used
516        let iteration_count = iteration_manager.current_iteration();
517        let (successful_runs, failed_runs) = metrics_collector.current_stats();
518        tracing::info!(
519            "Simulation completed: {}/{} iterations successful",
520            successful_runs,
521            iteration_count
522        );
523        tracing::info!("Seeds used: {:?}", iteration_manager.seeds_used());
524        if failed_runs > 0 {
525            tracing::warn!(
526                "{} iterations failed - check logs above for failing seeds",
527                failed_runs
528            );
529        }
530
531        // Final buggify reset to ensure no impact on subsequent code
532        crate::chaos::buggify_reset();
533
534        metrics_collector.generate_report(
535            iteration_count,
536            iteration_manager.seeds_used().to_vec(),
537            assertion_results,
538            assertion_violations,
539            coverage_violations,
540            exploration_report,
541            assertion_details,
542            bucket_summaries,
543        )
544    }
545}
546
547/// Build [`AssertionDetail`] vec from raw assertion slot snapshots.
548fn build_assertion_details(
549    slots: &[moonpool_explorer::AssertionSlotSnapshot],
550) -> Vec<super::report::AssertionDetail> {
551    use super::report::{AssertionDetail, AssertionStatus};
552    use moonpool_explorer::AssertKind;
553
554    slots
555        .iter()
556        .filter_map(|slot| {
557            let kind = AssertKind::from_u8(slot.kind)?;
558            let total = slot.pass_count.saturating_add(slot.fail_count);
559
560            // Skip unvisited assertions
561            if total == 0 && slot.frontier == 0 {
562                return None;
563            }
564
565            let status = match kind {
566                AssertKind::Always
567                | AssertKind::AlwaysOrUnreachable
568                | AssertKind::NumericAlways => {
569                    if slot.fail_count > 0 {
570                        AssertionStatus::Fail
571                    } else {
572                        AssertionStatus::Pass
573                    }
574                }
575                AssertKind::Sometimes | AssertKind::NumericSometimes => {
576                    if slot.pass_count > 0 {
577                        AssertionStatus::Pass
578                    } else {
579                        AssertionStatus::Miss
580                    }
581                }
582                AssertKind::Reachable => {
583                    if slot.pass_count > 0 {
584                        AssertionStatus::Pass
585                    } else {
586                        AssertionStatus::Miss
587                    }
588                }
589                AssertKind::Unreachable => {
590                    if slot.pass_count > 0 {
591                        AssertionStatus::Fail
592                    } else {
593                        AssertionStatus::Pass
594                    }
595                }
596                AssertKind::BooleanSometimesAll => {
597                    if slot.frontier > 0 {
598                        AssertionStatus::Pass
599                    } else {
600                        AssertionStatus::Miss
601                    }
602                }
603            };
604
605            Some(AssertionDetail {
606                msg: slot.msg.clone(),
607                kind,
608                pass_count: slot.pass_count,
609                fail_count: slot.fail_count,
610                watermark: slot.watermark,
611                frontier: slot.frontier,
612                status,
613            })
614        })
615        .collect()
616}
617
618/// Build [`BucketSiteSummary`] vec by grouping [`EachBucket`]s by site message.
619fn build_bucket_summaries(
620    buckets: &[moonpool_explorer::EachBucket],
621) -> Vec<super::report::BucketSiteSummary> {
622    use super::report::BucketSiteSummary;
623    use std::collections::HashMap;
624
625    let mut sites: HashMap<u32, BucketSiteSummary> = HashMap::new();
626
627    for bucket in buckets {
628        let entry = sites
629            .entry(bucket.site_hash)
630            .or_insert_with(|| BucketSiteSummary {
631                msg: bucket.msg_str().to_string(),
632                buckets_discovered: 0,
633                total_hits: 0,
634            });
635
636        entry.buckets_discovered += 1;
637        entry.total_hits += bucket.pass_count as u64;
638    }
639
640    let mut summaries: Vec<_> = sites.into_values().collect();
641    summaries.sort_by(|a, b| b.total_hits.cmp(&a.total_hits));
642    summaries
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use async_trait::async_trait;
649    use moonpool_core::RandomProvider;
650
651    use crate::SimulationResult;
652    use crate::runner::context::SimContext;
653
654    struct BasicWorkload;
655
656    #[async_trait(?Send)]
657    impl Workload for BasicWorkload {
658        fn name(&self) -> &str {
659            "test_workload"
660        }
661
662        async fn run(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
663            Ok(())
664        }
665    }
666
667    #[test]
668    fn test_simulation_builder_basic() {
669        let local_runtime = tokio::runtime::Builder::new_current_thread()
670            .enable_io()
671            .enable_time()
672            .build_local(Default::default())
673            .expect("Failed to build local runtime");
674
675        let report = local_runtime.block_on(async move {
676            SimulationBuilder::new()
677                .workload(BasicWorkload)
678                .set_iterations(3)
679                .set_debug_seeds(vec![1, 2, 3])
680                .run()
681                .await
682        });
683
684        assert_eq!(report.iterations, 3);
685        assert_eq!(report.successful_runs, 3);
686        assert_eq!(report.failed_runs, 0);
687        assert_eq!(report.success_rate(), 100.0);
688        assert_eq!(report.seeds_used, vec![1, 2, 3]);
689    }
690
691    struct FailingWorkload;
692
693    #[async_trait(?Send)]
694    impl Workload for FailingWorkload {
695        fn name(&self) -> &str {
696            "failing_workload"
697        }
698
699        async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
700            // Deterministic: fail if first random number is even
701            let random_num: u32 = ctx.random().random_range(0..100);
702            if random_num % 2 == 0 {
703                return Err(crate::SimulationError::InvalidState(
704                    "Test failure".to_string(),
705                ));
706            }
707            Ok(())
708        }
709    }
710
711    #[test]
712    fn test_simulation_builder_with_failures() {
713        let local_runtime = tokio::runtime::Builder::new_current_thread()
714            .enable_io()
715            .enable_time()
716            .build_local(Default::default())
717            .expect("Failed to build local runtime");
718
719        let report = local_runtime.block_on(async move {
720            SimulationBuilder::new()
721                .workload(FailingWorkload)
722                .set_iterations(10)
723                .run()
724                .await
725        });
726
727        assert_eq!(report.iterations, 10);
728        assert_eq!(
729            report.successful_runs + report.failed_runs,
730            10,
731            "all iterations should be accounted for"
732        );
733        assert!(
734            report.failed_runs > 0,
735            "expected at least one failure across 10 seeds"
736        );
737        assert!(
738            report.successful_runs > 0,
739            "expected at least one success across 10 seeds"
740        );
741    }
742}