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, RangeInclusive};
8use std::time::Duration;
9
10use super::wall_clock::Instant;
11use tracing::instrument;
12
13use crate::SimulationError;
14use crate::observability::{Invariant, SimulationLayer, SimulationLayerHandle, TraceQuery};
15use crate::runner::fault_injector::FaultInjector;
16use crate::runner::locality::{LocalityConfig, MachineRegistry};
17use crate::runner::process::{Attrition, Process};
18use crate::runner::tags::TagDistribution;
19use crate::runner::workload::Workload;
20
21use super::orchestrator::{
22    GenerateReportInputs, IterationManager, MetricsCollector, OrchestrateInputs, OrchestrateOutput,
23    WorkloadOrchestrator,
24};
25
26/// Client identity information for a single workload instance.
27#[derive(Debug, Clone, Copy)]
28pub(crate) struct WorkloadClientInfo {
29    /// The resolved client ID for this instance.
30    pub(crate) client_id: usize,
31    /// Total number of workload instances sharing this builder entry.
32    pub(crate) client_count: usize,
33}
34
35/// Inputs to `run_orchestrator_blocking`.
36struct RunOrchestratorInputs<'a> {
37    seed: u64,
38    iteration_count: usize,
39    workloads: Vec<Box<dyn Workload>>,
40    workload_info: Vec<(String, String)>,
41    client_info: Vec<WorkloadClientInfo>,
42    process_config: Option<super::orchestrator::ProcessConfig<'a>>,
43    sim: crate::sim::SimWorld,
44    fault_injectors: Vec<Box<dyn FaultInjector>>,
45    chaos_duration: Option<Duration>,
46    obs_handle: SimulationLayerHandle,
47    run_time_budget: Duration,
48}
49
50/// Outcome of an orchestration attempt.
51type OrchestrationOutcome = Result<OrchestrateOutput, (Vec<u64>, usize)>;
52
53/// Per-run accumulators passed into the final-report builder.
54struct FinalReportInputs {
55    converged: bool,
56    /// Saturation outcome captured during the last scan (`UntilCoverageStable`).
57    saturation: Option<super::report::SaturationReport>,
58    #[cfg(feature = "exploration")]
59    total_exploration_timelines: u64,
60    #[cfg(feature = "exploration")]
61    total_exploration_fork_points: u64,
62    #[cfg(feature = "exploration")]
63    total_exploration_bugs: u64,
64    #[cfg(feature = "exploration")]
65    bug_recipes: Vec<super::report::BugRecipe>,
66    #[cfg(feature = "exploration")]
67    per_seed_timelines: Vec<u64>,
68}
69
70/// Aggregated state passed into the convergence / plateau check helper.
71struct ConvergenceState<'a> {
72    iteration_control: &'a IterationControl,
73    iteration_count: usize,
74    reached_sometimes: &'a std::collections::HashSet<String>,
75    all_sometimes_count: usize,
76    /// Whether fork-based exploration is active (selects sancov history vs
77    /// the live BSS counter reader for the code-coverage signal).
78    exploration_active: bool,
79    prev_signal: &'a mut usize,
80    plateau_count: &'a mut usize,
81    /// Captures the saturation outcome (signal source + coverage numbers).
82    saturation: &'a mut Option<super::report::SaturationReport>,
83    already_converged: bool,
84}
85
86impl RunState {
87    /// Initialise per-run accumulators from the builder's configuration.
88    fn new(builder: &SimulationBuilder) -> Self {
89        let iteration_manager =
90            IterationManager::new(builder.iteration_control.clone(), builder.seeds.clone());
91        let progress_milestone = iteration_manager
92            .max_iterations()
93            .map(|max| std::cmp::max(max / 10, 1));
94        Self {
95            iteration_manager,
96            metrics_collector: MetricsCollector::new(),
97            progress_milestone,
98            pending_return_map: Vec::new(),
99            #[cfg(feature = "exploration")]
100            total_exploration_timelines: 0,
101            #[cfg(feature = "exploration")]
102            total_exploration_fork_points: 0,
103            #[cfg(feature = "exploration")]
104            total_exploration_bugs: 0,
105            #[cfg(feature = "exploration")]
106            bug_recipes: Vec::new(),
107            #[cfg(feature = "exploration")]
108            per_seed_timelines: Vec::new(),
109            reached_sometimes: std::collections::HashSet::new(),
110            prev_signal: 0,
111            converged: false,
112            plateau_count: 0,
113            saturation: None,
114        }
115    }
116}
117
118/// Accumulated mutable state threaded through [`SimulationBuilder::run`].
119struct RunState {
120    iteration_manager: IterationManager,
121    metrics_collector: MetricsCollector,
122    /// Iteration interval at which progress is logged (`None` for unbounded runs).
123    progress_milestone: Option<usize>,
124    /// Map for routing iteration-resolved workloads back to their entry slots,
125    /// stashed between [`SimulationBuilder::run_orchestrator_for_iteration`]
126    /// and [`SimulationBuilder::handle_orchestration_result`].
127    pending_return_map: Vec<Option<usize>>,
128    // Exploration accumulators (only populated/read with the `exploration` feature).
129    #[cfg(feature = "exploration")]
130    total_exploration_timelines: u64,
131    #[cfg(feature = "exploration")]
132    total_exploration_fork_points: u64,
133    #[cfg(feature = "exploration")]
134    total_exploration_bugs: u64,
135    #[cfg(feature = "exploration")]
136    bug_recipes: Vec<super::report::BugRecipe>,
137    #[cfg(feature = "exploration")]
138    per_seed_timelines: Vec<u64>,
139    // Saturation tracking (`UntilCoverageStable`).
140    reached_sometimes: std::collections::HashSet<String>,
141    /// Previous progress-signal value (code edges, or reached-assertion count
142    /// in the no-sancov fallback). Both signals are monotonic non-decreasing.
143    prev_signal: usize,
144    converged: bool,
145    plateau_count: usize,
146    /// Saturation outcome captured during the last scan, surfaced in the report.
147    saturation: Option<super::report::SaturationReport>,
148}
149
150/// Resolved workload entries for a single iteration.
151struct ResolvedEntries {
152    workloads: Vec<Box<dyn Workload>>,
153    /// `return_map[i] = Some(entry_idx)` means `workloads[i]` should be
154    /// returned to `entries[entry_idx]` after the iteration.
155    return_map: Vec<Option<usize>>,
156    /// Client identity info parallel to `workloads`.
157    client_info: Vec<WorkloadClientInfo>,
158}
159use super::report::{SimulationMetrics, SimulationReport};
160
161/// Configuration for how many iterations a simulation should run.
162///
163/// Provides flexible control over simulation execution duration and completion criteria.
164#[derive(Debug, Clone)]
165pub enum IterationControl {
166    /// Run a fixed number of iterations with specific seeds
167    FixedCount(usize),
168    /// Run for a specific duration of wall-clock time
169    TimeLimit(Duration),
170    /// Stop when the system is saturated: every observed
171    /// `assert_sometimes!` / `assert_reachable!` has fired **and** code
172    /// coverage has not grown for `plateau_seeds` consecutive seeds.
173    ///
174    /// Uses real LLVM sancov code coverage when the binary is instrumented
175    /// (i.e. built via `cargo xtask sim run`); otherwise falls back to the
176    /// count of distinct reached sometimes/reachable assertion slots. Works
177    /// with or without [`SimulationBuilder::enable_exploration`]; no fork
178    /// occurs unless exploration is explicitly enabled.
179    UntilCoverageStable {
180        /// Number of consecutive seeds without coverage growth required to stop.
181        plateau_seeds: usize,
182        /// Maximum number of seeds before stopping regardless (safety cap).
183        max_iterations: usize,
184    },
185}
186
187/// How many instances of a workload to spawn per iteration.
188///
189/// Use `Fixed` for deterministic topologies or `Random` for chaos testing
190/// with varying cluster sizes.
191///
192/// # Examples
193///
194/// ```ignore
195/// // Always 3 replicas
196/// WorkloadCount::Fixed(3)
197///
198/// // 1 to 5 replicas, randomized per iteration
199/// WorkloadCount::Random(1..6)
200/// ```
201#[derive(Debug, Clone)]
202pub enum WorkloadCount {
203    /// Spawn exactly N instances every iteration.
204    Fixed(usize),
205    /// Spawn a random number of instances in `[start..end)` per iteration,
206    /// using the simulation RNG (deterministic per seed).
207    Random(Range<usize>),
208}
209
210impl WorkloadCount {
211    /// Resolve the count for the current iteration.
212    /// For `Random`, uses the sim RNG which must already be seeded.
213    fn resolve(&self) -> usize {
214        match self {
215            WorkloadCount::Fixed(n) => *n,
216            WorkloadCount::Random(range) => crate::sim::sim_random_range(range.clone()),
217        }
218    }
219}
220
221/// Strategy for assigning client IDs to workload instances.
222///
223/// Inspired by `FoundationDB`'s `WorkloadContext.clientId`, but more
224/// programmable. The resolved client ID is available via
225/// [`SimContext::client_id()`](super::context::SimContext::client_id).
226///
227/// # Examples
228///
229/// ```ignore
230/// // FDB-style sequential: IDs 0, 1, 2
231/// ClientId::Fixed(0)
232///
233/// // Sequential starting from 10: IDs 10, 11, 12
234/// ClientId::Fixed(10)
235///
236/// // Random IDs in [100..200) per instance
237/// ClientId::RandomRange(100..200)
238/// ```
239#[derive(Debug, Clone, PartialEq)]
240pub enum ClientId {
241    /// Sequential IDs starting from `base`: instance 0 gets `base`,
242    /// instance 1 gets `base + 1`, and so on.
243    Fixed(usize),
244    /// Random ID drawn from `[start..end)` per instance,
245    /// using the simulation RNG (deterministic per seed).
246    /// IDs are not guaranteed unique across instances.
247    RandomRange(Range<usize>),
248}
249
250impl Default for ClientId {
251    fn default() -> Self {
252        Self::Fixed(0)
253    }
254}
255
256impl ClientId {
257    /// Resolve a client ID for the given instance index.
258    fn resolve(&self, index: usize) -> usize {
259        match self {
260            ClientId::Fixed(base) => base + index,
261            ClientId::RandomRange(range) => crate::sim::sim_random_range(range.clone()),
262        }
263    }
264}
265
266/// How many process instances to spawn per iteration.
267///
268/// Use `Fixed` for deterministic topologies or `Range` for chaos testing
269/// with varying cluster sizes.
270///
271/// # Examples
272///
273/// ```ignore
274/// // Always 3 server processes
275/// ProcessCount::Fixed(3)
276///
277/// // 3 to 7 server processes, randomized per iteration
278/// ProcessCount::Range(3..=7)
279/// ```
280#[derive(Debug, Clone, PartialEq)]
281pub enum ProcessCount {
282    /// Spawn exactly N process instances every iteration.
283    Fixed(usize),
284    /// Spawn a random number in `[start..=end]` per iteration,
285    /// using the simulation RNG (deterministic per seed).
286    Range(RangeInclusive<usize>),
287}
288
289impl ProcessCount {
290    /// Resolve the count for the current iteration.
291    pub(crate) fn resolve(&self) -> usize {
292        match self {
293            ProcessCount::Fixed(n) => *n,
294            ProcessCount::Range(range) => {
295                let start = *range.start();
296                let end = *range.end() + 1; // RangeInclusive -> exclusive for sim_random_range
297                if start >= end {
298                    return start;
299                }
300                crate::sim::sim_random_range(start..end)
301            }
302        }
303    }
304}
305
306impl From<usize> for ProcessCount {
307    fn from(n: usize) -> Self {
308        ProcessCount::Fixed(n)
309    }
310}
311
312impl From<RangeInclusive<usize>> for ProcessCount {
313    fn from(range: RangeInclusive<usize>) -> Self {
314        ProcessCount::Range(range)
315    }
316}
317
318/// Internal storage for a process entry in the builder.
319pub(crate) struct ProcessEntry {
320    pub(crate) count: ProcessCount,
321    pub(crate) factory: Box<dyn Fn() -> Box<dyn Process>>,
322    pub(crate) tags: TagDistribution,
323    pub(crate) name: String,
324    /// Failure-domain topology. When `Some`, it determines the process count
325    /// (sampled per seed) and `count` is ignored.
326    pub(crate) locality: Option<LocalityConfig>,
327}
328
329/// Internal storage for workload entries in the builder.
330enum WorkloadEntry {
331    /// Single instance, reused across iterations (from `.workload()`).
332    Instance(Option<Box<dyn Workload>>, ClientId),
333    /// Factory-based, fresh instances per iteration (from `.workloads()`).
334    Factory {
335        count: WorkloadCount,
336        client_id: ClientId,
337        factory: Box<dyn Fn(usize) -> Box<dyn Workload>>,
338    },
339}
340
341/// How an enabled chaos surface is sampled each seed.
342///
343/// This is the *sampling-strategy* axis, orthogonal to *which* surface is
344/// enabled (see [`Chaos`]). It does not apply to the workload operation-alphabet
345/// swarm, which is a test-driver concern with its own switch
346/// ([`SimulationBuilder::swarm_operations`]).
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum ChaosMode {
349    /// Full surface every seed: all sub-families active, intensities seed-randomized.
350    ///
351    /// Network/storage use `random_for_seed`; attrition uses the configured regime
352    /// as written (reboot kinds still vary per seed via the in-run RNG).
353    Random,
354    /// Swarm testing (Groce et al., ISSTA 2012): a per-seed random *subset* of
355    /// sub-families is active, the rest fully off.
356    ///
357    /// Network/storage use `swarm_for_seed`; attrition randomizes the reboot regime
358    /// per seed, including the never-reboot and single-mode cases.
359    Swarm,
360}
361
362/// A chaos surface to enable, and how to sample it per seed.
363///
364/// Pass these to [`SimulationBuilder::enable_chaos`]. A surface absent from the
365/// enable list is **off**. The two axes are orthogonal: enabling a surface
366/// (which variant) is distinct from how it is sampled (its [`ChaosMode`]).
367#[derive(Debug, Clone, PartialEq)]
368pub enum Chaos {
369    /// Network faults (clogging, partitions, bit-flips, random close, …).
370    Network(ChaosMode),
371    /// Storage faults (read/write/crash/misdirect/phantom/sync).
372    Storage(ChaosMode),
373    /// Process attrition (chaos reboots). Carries the base regime; requires
374    /// [`chaos_duration`](SimulationBuilder::chaos_duration) to actually run.
375    Attrition {
376        /// Base reboot regime (weights, `max_dead`, delays).
377        config: Attrition,
378        /// How the regime is sampled per seed.
379        mode: ChaosMode,
380    },
381    /// Buggify-driven knob value-perturbation. An additive *modifier*, not a
382    /// surface of its own: on enabled network/storage surfaces it occasionally
383    /// spikes individual knob *values* (latencies, IOPS, fault rates) to an
384    /// extreme within bounds, per FDB's `if (randomize && BUGGIFY) KNOB =
385    /// random(lo, hi)`. Composes with [`ChaosMode::Random`]/[`ChaosMode::Swarm`].
386    BuggifyKnobs,
387}
388
389/// Builder pattern for configuring and running simulation experiments.
390pub struct SimulationBuilder {
391    iteration_control: IterationControl,
392    entries: Vec<WorkloadEntry>,
393    process_entry: Option<ProcessEntry>,
394    attrition: Option<Attrition>,
395    attrition_mode: ChaosMode,
396    seeds: Vec<u64>,
397    network_chaos: Option<ChaosMode>,
398    storage_chaos: Option<ChaosMode>,
399    /// Buggify-driven knob value-perturbation, enabled via [`Chaos::BuggifyKnobs`].
400    /// Internal flag (not a public builder method) so the opt-in stays inside the
401    /// `enable_chaos`/`Chaos` model.
402    buggify_knobs: bool,
403    swarm_operations: bool,
404    invariants: Vec<Box<dyn Invariant + Send>>,
405    fault_injectors: Vec<Box<dyn FaultInjector>>,
406    chaos_duration: Option<Duration>,
407    exploration_config: Option<crate::chaos::exploration_glue::ExplorationConfig>,
408    before_iteration_hooks: Vec<Box<dyn FnMut()>>,
409    seed_warning_timeout: Option<Duration>,
410    run_time_budget: Duration,
411}
412
413impl Default for SimulationBuilder {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419impl SimulationBuilder {
420    /// Create a new empty simulation builder.
421    #[must_use]
422    pub fn new() -> Self {
423        Self {
424            iteration_control: IterationControl::UntilCoverageStable {
425                plateau_seeds: 10,
426                max_iterations: 1000,
427            },
428            entries: Vec::new(),
429            process_entry: None,
430            attrition: None,
431            attrition_mode: ChaosMode::Random,
432            seeds: Vec::new(),
433            network_chaos: None,
434            storage_chaos: None,
435            buggify_knobs: false,
436            swarm_operations: false,
437            invariants: Vec::new(),
438            fault_injectors: Vec::new(),
439            chaos_duration: None,
440            exploration_config: None,
441            before_iteration_hooks: Vec::new(),
442            seed_warning_timeout: None,
443            run_time_budget: super::orchestrator::DEFAULT_RUN_TIME_BUDGET,
444        }
445    }
446
447    /// Add a single workload instance to the simulation.
448    ///
449    /// The instance is reused across iterations (the `run()` method is called
450    /// each iteration on the same struct). Gets `client_id = 0`, `client_count = 1`.
451    #[must_use]
452    pub fn workload(mut self, w: impl Workload) -> Self {
453        self.entries.push(WorkloadEntry::Instance(
454            Some(Box::new(w)),
455            ClientId::default(),
456        ));
457        self
458    }
459
460    /// Add server processes to the simulation.
461    ///
462    /// Processes represent the **system under test** — they can be killed and
463    /// restarted (rebooted). A fresh instance is created from the factory on
464    /// every boot.
465    ///
466    /// The `count` parameter accepts either a fixed `usize` or a
467    /// `RangeInclusive<usize>` for seeded random count per iteration.
468    ///
469    /// Only one `.processes()` call is supported per builder. Subsequent calls
470    /// overwrite the previous one.
471    ///
472    /// # Examples
473    ///
474    /// ```ignore
475    /// // Fixed 3 server processes
476    /// builder.processes(3, || Box::new(MyNode::new()))
477    ///
478    /// // 3 to 7 processes, randomized per iteration
479    /// builder.processes(3..=7, || Box::new(MyNode::new()))
480    /// ```
481    #[must_use]
482    pub fn processes(
483        mut self,
484        count: impl Into<ProcessCount>,
485        factory: impl Fn() -> Box<dyn Process> + 'static,
486    ) -> Self {
487        let sample = factory();
488        let name = sample.name().to_string();
489        drop(sample);
490        self.process_entry = Some(ProcessEntry {
491            count: count.into(),
492            factory: Box::new(factory),
493            tags: TagDistribution::new(),
494            name,
495            locality: None,
496        });
497        self
498    }
499
500    /// Register server processes laid out across a failure-domain topology.
501    ///
502    /// Unlike [`processes`](Self::processes), the [`LocalityConfig`] *is* the
503    /// spawn spec: it determines the process count (sampled per seed), assigns
504    /// each process a datacenter / zone / machine, and lets machine- and
505    /// zone-scoped attrition reboot collocated processes together. Calling this
506    /// replaces any prior `.processes()` / `.cluster()` registration.
507    ///
508    /// Tags ([`tags`](Self::tags)) remain orthogonal and may still be chained.
509    ///
510    /// # Examples
511    ///
512    /// ```ignore
513    /// // 3 datacenters × 3 zones × 3 machines × 1 process = 27 processes,
514    /// // with the datacenter count randomized per seed.
515    /// builder.cluster(
516    ///     LocalityConfig::new(1..=3, 3, 3, 1),
517    ///     || Box::new(MyNode::new()),
518    /// )
519    /// ```
520    #[must_use]
521    pub fn cluster(
522        mut self,
523        config: LocalityConfig,
524        factory: impl Fn() -> Box<dyn Process> + 'static,
525    ) -> Self {
526        let sample = factory();
527        let name = sample.name().to_string();
528        drop(sample);
529        self.process_entry = Some(ProcessEntry {
530            // `count` is unused when locality is present; the topology decides it.
531            count: ProcessCount::Fixed(0),
532            factory: Box::new(factory),
533            tags: TagDistribution::new(),
534            name,
535            locality: Some(config),
536        });
537        self
538    }
539
540    /// Attach tag distribution to the last `.processes()` call.
541    ///
542    /// Tags are distributed round-robin across process instances. Each tag
543    /// dimension is distributed independently.
544    ///
545    /// # Errors
546    ///
547    /// Returns `SimulationError::InvalidState` if called without a preceding
548    /// `.processes()` call.
549    ///
550    /// # Examples
551    ///
552    /// ```ignore
553    /// // 5 processes: dc cycles east/west/eu, rack cycles r1/r2
554    /// builder.processes(5, || Box::new(MyNode::new()))
555    ///     .tags(&[
556    ///         ("dc", &["east", "west", "eu"]),
557    ///         ("rack", &["r1", "r2"]),
558    ///     ])?
559    /// ```
560    pub fn tags(mut self, dimensions: &[(&str, &[&str])]) -> Result<Self, SimulationError> {
561        let entry = self.process_entry.as_mut().ok_or_else(|| {
562            SimulationError::InvalidState("tags() must be called after processes()".into())
563        })?;
564        for (key, values) in dimensions {
565            entry.tags.add(key, values);
566        }
567        Ok(self)
568    }
569
570    /// Set built-in attrition for automatic process reboots during chaos phase.
571    ///
572    /// Attrition randomly kills and restarts server processes. It respects
573    /// `max_dead` to limit the number of simultaneously dead processes.
574    ///
575    /// **Requires** [`.chaos_duration()`](Self::chaos_duration) — attrition injectors
576    /// only run during the chaos phase. Without a chaos duration, the injector
577    /// will not be spawned.
578    ///
579    /// For custom fault injection, use `.fault()` with a [`FaultInjector`] instead.
580    #[must_use]
581    pub fn attrition(mut self, config: Attrition) -> Self {
582        self.attrition = Some(config);
583        self
584    }
585
586    /// Add multiple workload instances from a factory.
587    ///
588    /// The factory receives an instance index (0-based) and must return a fresh
589    /// workload. Instances are created each iteration and dropped afterward.
590    /// Client IDs default to sequential starting from 0 (FDB-style).
591    ///
592    /// The workload is responsible for its own `name()` — use the index to
593    /// produce unique names when count > 1 (e.g., `format!("client-{i}")`).
594    ///
595    /// # Examples
596    ///
597    /// ```ignore
598    /// // 3 fixed replicas
599    /// builder.workloads(WorkloadCount::Fixed(3), |i| Box::new(ReplicaWorkload::new(i)))
600    ///
601    /// // 1–5 random clients
602    /// builder.workloads(WorkloadCount::Random(1..6), |i| Box::new(ClientWorkload::new(i)))
603    /// ```
604    #[must_use]
605    pub fn workloads(
606        mut self,
607        count: WorkloadCount,
608        factory: impl Fn(usize) -> Box<dyn Workload> + 'static,
609    ) -> Self {
610        self.entries.push(WorkloadEntry::Factory {
611            count,
612            client_id: ClientId::default(),
613            factory: Box::new(factory),
614        });
615        self
616    }
617
618    /// Add an invariant to be checked after every simulation step.
619    #[must_use]
620    pub fn invariant<I: Invariant>(mut self, i: I) -> Self {
621        self.invariants.push(Box::new(i));
622        self
623    }
624
625    /// Add a closure-based invariant.
626    #[must_use]
627    pub fn invariant_fn(
628        mut self,
629        name: impl Into<String>,
630        f: impl Fn(&dyn TraceQuery, u64) + Send + 'static,
631    ) -> Self {
632        self.invariants
633            .push(crate::observability::invariant_fn(name, f));
634        self
635    }
636
637    /// Add a fault injector to run during the chaos phase.
638    #[must_use]
639    pub fn fault(mut self, f: impl FaultInjector) -> Self {
640        self.fault_injectors.push(Box::new(f));
641        self
642    }
643
644    /// Set the chaos phase duration.
645    ///
646    /// When set, fault injectors run concurrently with workloads for this
647    /// duration. After it elapses, faults stop and the system continues
648    /// until all workloads complete. A settle phase then drains remaining
649    /// events before checks run.
650    #[must_use]
651    pub fn chaos_duration(mut self, duration: Duration) -> Self {
652        self.chaos_duration = Some(duration);
653        self
654    }
655
656    /// Set the number of iterations to run.
657    #[must_use]
658    pub fn set_iterations(mut self, iterations: usize) -> Self {
659        self.iteration_control = IterationControl::FixedCount(iterations);
660        self
661    }
662
663    /// Set the wall-clock time threshold for warning about slow seeds.
664    ///
665    /// When a seed takes longer than this duration, a `tracing::warn!` is emitted.
666    /// If not set, no slow-seed warnings are produced.
667    #[must_use]
668    pub fn seed_warning_timeout(mut self, timeout: Duration) -> Self {
669        self.seed_warning_timeout = Some(timeout);
670        self
671    }
672
673    /// Set the virtual-time budget for a single run phase.
674    ///
675    /// If simulated time advances past this bound while one or more workloads
676    /// are still running, the orchestrator first triggers a graceful shutdown
677    /// and — if simulated time keeps climbing by another full budget while
678    /// workloads remain — declares the run deadlocked.
679    ///
680    /// This is a deterministic safety net for a *self-perpetuating timer*: a
681    /// detached task (e.g. a reconnect / keepalive loop) that re-arms a
682    /// [`crate::TimeProvider::sleep`] every tick keeps the event queue
683    /// non-empty forever, so the no-progress deadlock detector never fires
684    /// even though no workload-relevant progress is being made. The budget
685    /// turns that silent hang into an actionable deadlock failure.
686    ///
687    /// The decision is a pure function of the simulated event schedule (no
688    /// wall clock, no RNG), so it never perturbs replay determinism. The
689    /// default (one simulated hour) is deliberately generous; raise it for
690    /// legitimately long simulations.
691    #[must_use]
692    pub fn run_time_budget(mut self, budget: Duration) -> Self {
693        self.run_time_budget = budget;
694        self
695    }
696
697    /// Run until the system is saturated: every observed
698    /// `assert_sometimes!` / `assert_reachable!` assertion has fired **and**
699    /// code coverage has not grown for `plateau_seeds` consecutive seeds
700    /// (capped at `max_iterations`).
701    ///
702    /// Uses real LLVM sancov code coverage when the binary is instrumented
703    /// (built via `cargo xtask sim run`); otherwise falls back to assertion-slot
704    /// coverage. Works with or without [`SimulationBuilder::enable_exploration`];
705    /// no fork occurs unless exploration is explicitly enabled.
706    /// e.g. `until_coverage_stable(10, 5000)`.
707    #[must_use]
708    pub fn until_coverage_stable(mut self, plateau_seeds: usize, max_iterations: usize) -> Self {
709        self.iteration_control = IterationControl::UntilCoverageStable {
710            plateau_seeds,
711            max_iterations,
712        };
713        self
714    }
715
716    /// Register a callback invoked at the start of each simulation iteration.
717    ///
718    /// Use this to reset shared state (directories, membership, stores) that
719    /// lives outside the builder and is shared via `Rc` across iterations.
720    #[must_use]
721    pub fn before_iteration(mut self, f: impl FnMut() + 'static) -> Self {
722        self.before_iteration_hooks.push(Box::new(f));
723        self
724    }
725
726    /// Set specific seeds for deterministic debugging and regression testing.
727    #[must_use]
728    pub fn set_debug_seeds(mut self, seeds: Vec<u64>) -> Self {
729        self.seeds = seeds;
730        self
731    }
732
733    /// Enable chaos surfaces and choose how each is sampled per seed.
734    ///
735    /// Each [`Chaos`] entry turns on one surface (network / storage / attrition)
736    /// in a given [`ChaosMode`] — `Random` (full surface every seed) or `Swarm`
737    /// (a per-seed random *subset* of sub-families, the rest fully off). A surface
738    /// not listed stays off. Later entries override earlier ones for the same
739    /// surface.
740    ///
741    /// `Swarm` mode defeats passive suppression: when every fault is always
742    /// slightly on (`Random`) families crowd each other out and the extreme
743    /// single-family configs that surface bugs almost never occur. Subset
744    /// decisions are drawn from a dedicated `CONFIG_RNG` stream, so they are
745    /// reproducible per seed yet never perturb in-run randomness or fork-explorer
746    /// replay.
747    ///
748    /// The workload operation-alphabet swarm is a separate, test-driver concern —
749    /// see [`swarm_operations`](Self::swarm_operations).
750    ///
751    /// # Example
752    ///
753    /// ```ignore
754    /// builder.enable_chaos([
755    ///     Chaos::Network(ChaosMode::Swarm),
756    ///     Chaos::Storage(ChaosMode::Swarm),
757    /// ]);
758    /// ```
759    #[must_use]
760    pub fn enable_chaos(mut self, surfaces: impl IntoIterator<Item = Chaos>) -> Self {
761        for surface in surfaces {
762            match surface {
763                Chaos::Network(mode) => self.network_chaos = Some(mode),
764                Chaos::Storage(mode) => self.storage_chaos = Some(mode),
765                Chaos::Attrition { config, mode } => {
766                    self.attrition = Some(config);
767                    self.attrition_mode = mode;
768                }
769                Chaos::BuggifyKnobs => self.buggify_knobs = true,
770            }
771        }
772        self
773    }
774
775    /// Enable per-seed swarm testing of the workload operation alphabet.
776    ///
777    /// When enabled, each seed exposes a random *subset* of each workload's
778    /// operation alphabet via [`swarm_op_enabled`](crate::swarm_op_enabled), so
779    /// bugs reachable only when whole operation groups are suppressed become
780    /// reachable across seeds. Decisions come from a dedicated per-seed stream and
781    /// are reproducible. Independent of [`enable_chaos`](Self::enable_chaos).
782    #[must_use]
783    pub fn swarm_operations(mut self) -> Self {
784        self.swarm_operations = true;
785        self
786    }
787
788    /// Enable fork-based multiverse exploration.
789    ///
790    /// When enabled, the simulation will fork child processes at assertion
791    /// discovery points to explore alternate timelines with different seeds.
792    /// Requires the `exploration` feature.
793    #[cfg(feature = "exploration")]
794    #[must_use]
795    pub fn enable_exploration(
796        mut self,
797        config: crate::chaos::exploration_glue::ExplorationConfig,
798    ) -> Self {
799        self.exploration_config = Some(config);
800        self
801    }
802
803    /// Resolve all entries into a flat workload list for one iteration.
804    fn resolve_entries(&mut self) -> ResolvedEntries {
805        let mut workloads = Vec::new();
806        let mut return_map = Vec::new();
807        let mut client_info = Vec::new();
808
809        for (entry_idx, entry) in self.entries.iter_mut().enumerate() {
810            match entry {
811                WorkloadEntry::Instance(opt, cid) => {
812                    if let Some(w) = opt.take() {
813                        return_map.push(Some(entry_idx));
814                        client_info.push(WorkloadClientInfo {
815                            client_id: cid.resolve(0),
816                            client_count: 1,
817                        });
818                        workloads.push(w);
819                    }
820                }
821                WorkloadEntry::Factory {
822                    count,
823                    client_id,
824                    factory,
825                } => {
826                    let n = count.resolve();
827                    for i in 0..n {
828                        return_map.push(None);
829                        client_info.push(WorkloadClientInfo {
830                            client_id: client_id.resolve(i),
831                            client_count: n,
832                        });
833                        workloads.push(factory(i));
834                    }
835                }
836            }
837        }
838
839        ResolvedEntries {
840            workloads,
841            return_map,
842            client_info,
843        }
844    }
845
846    /// Return instance-based workloads to their entry slots after an iteration.
847    fn return_entries(
848        &mut self,
849        workloads: Vec<Box<dyn Workload>>,
850        return_map: Vec<Option<usize>>,
851    ) {
852        for (w, slot) in workloads.into_iter().zip(return_map) {
853            if let Some(entry_idx) = slot
854                && let WorkloadEntry::Instance(opt, _) = &mut self.entries[entry_idx]
855            {
856                *opt = Some(w);
857            }
858            // Factory-created workloads are dropped
859        }
860    }
861
862    /// Spin up a fresh deterministic executor, run the orchestrator on it,
863    /// and return its outcome.
864    fn run_orchestrator_blocking(inputs: RunOrchestratorInputs<'_>) -> OrchestrationOutcome {
865        let RunOrchestratorInputs {
866            seed,
867            iteration_count,
868            workloads,
869            workload_info,
870            client_info,
871            process_config,
872            sim,
873            fault_injectors,
874            chaos_duration,
875            obs_handle,
876            run_time_budget,
877        } = inputs;
878        // Fresh executor per iteration: dropping it cancels every task that
879        // leaked past the settle phase, so no state crosses into the next seed
880        // (the same contract dropping the per-iteration tokio runtime gave).
881        let mut executor = crate::executor::Executor::new(seed);
882        executor.block_on(async move {
883            WorkloadOrchestrator::orchestrate_workloads(OrchestrateInputs {
884                workloads,
885                fault_injectors,
886                obs: obs_handle,
887                workload_info: &workload_info,
888                client_info: &client_info,
889                process_config,
890                seed,
891                sim,
892                chaos_duration,
893                iteration_count,
894                run_time_budget,
895            })
896            .await
897        })
898    }
899
900    /// Build a `SimWorld` for the iteration, picking each chaos surface's config
901    /// from its [`ChaosMode`]: `None` ⇒ default (off), `Random` ⇒ `random_for_seed`,
902    /// `Swarm` ⇒ `swarm_for_seed`.
903    ///
904    /// The network mask (if any) draws from `CONFIG_RNG` before the storage mask,
905    /// keeping the per-seed draw order fixed and reproducible.
906    fn build_sim_for_iteration(
907        network_chaos: Option<ChaosMode>,
908        storage_chaos: Option<ChaosMode>,
909        buggify_knobs: bool,
910        seed: u64,
911    ) -> crate::sim::SimWorld {
912        let mut network_config = match network_chaos {
913            Some(ChaosMode::Swarm) => crate::NetworkConfiguration::swarm_for_seed(),
914            Some(ChaosMode::Random) => crate::NetworkConfiguration::random_for_seed(),
915            None => crate::NetworkConfiguration::default(),
916        };
917        let mut storage_config = match storage_chaos {
918            Some(ChaosMode::Swarm) => crate::storage::StorageConfiguration::swarm_for_seed(),
919            Some(ChaosMode::Random) => crate::storage::StorageConfiguration::random_for_seed(),
920            None => crate::storage::StorageConfiguration::default(),
921        };
922        // Buggify value-perturbation is a modifier layered on top of an enabled
923        // surface — only spike knobs where chaos is actually on, so it never
924        // silently switches on a fault family that wasn't enabled. Draws from
925        // `SIM_RNG` (buggify is live by now; see `reset_per_iteration_state`).
926        if buggify_knobs {
927            if network_chaos.is_some() {
928                network_config.chaos.apply_buggify_knobs();
929            }
930            if storage_chaos.is_some() {
931                storage_config.apply_buggify_knobs();
932            }
933        }
934        let mut sim = crate::sim::SimWorld::new_with_network_config_and_seed(network_config, seed);
935        sim.set_storage_config(storage_config);
936        sim
937    }
938
939    /// Drain user-provided fault injectors and, when present, append the
940    /// built-in attrition injector.
941    fn collect_fault_injectors(
942        user_injectors: &mut Vec<Box<dyn FaultInjector>>,
943        attrition: Option<&Attrition>,
944    ) -> Vec<Box<dyn FaultInjector>> {
945        let mut fault_injectors = std::mem::take(user_injectors);
946        if let Some(attrition) = attrition {
947            fault_injectors.push(Box::new(
948                crate::runner::fault_injector::AttritionInjector::new(attrition.clone()),
949            ));
950        }
951        fault_injectors
952    }
953
954    /// Build an early-exit report on deadlock: snapshot the assertion
955    /// state, reset buggify, and consume the metrics collector.
956    fn build_early_exit_report(
957        metrics_collector: MetricsCollector,
958        iteration_count: usize,
959        seeds_used: Vec<u64>,
960    ) -> SimulationReport {
961        let assertion_results = crate::chaos::assertion_results();
962        let (assertion_violations, coverage_violations) =
963            crate::chaos::validate_assertion_contracts();
964        crate::chaos::buggify_reset();
965        metrics_collector.generate_report(GenerateReportInputs {
966            iteration_count,
967            seeds_used,
968            assertion_results,
969            assertion_violations,
970            coverage_violations,
971            exploration: None,
972            assertion_details: Vec::new(),
973            bucket_summaries: Vec::new(),
974            convergence_timeout: false,
975            saturation: None,
976        })
977    }
978
979    /// Check whether the `UntilCoverageStable` saturation condition has been
980    /// met this iteration. Returns the new `converged` flag.
981    ///
982    /// Saturation = every observed sometimes/reachable assertion has fired AND
983    /// the progress signal (real code coverage when sancov is available, else
984    /// the reached-assertion count) has not grown for `plateau_seeds`
985    /// consecutive seeds. Both signals are monotonic non-decreasing, so
986    /// `current == prev` marks a quiet seed.
987    fn check_convergence_or_plateau(state: ConvergenceState<'_>) -> bool {
988        let ConvergenceState {
989            iteration_control,
990            iteration_count,
991            reached_sometimes,
992            all_sometimes_count,
993            exploration_active,
994            prev_signal,
995            plateau_count,
996            saturation,
997            already_converged,
998        } = state;
999        if already_converged {
1000            return true;
1001        }
1002        let IterationControl::UntilCoverageStable { plateau_seeds, .. } = iteration_control else {
1003            return false;
1004        };
1005
1006        // Pick the progress signal: real code coverage when instrumented, else
1007        // the count of distinct reached sometimes/reachable slots.
1008        let edges = crate::chaos::exploration_glue::code_coverage_edges(exploration_active);
1009        let (signal, current) = match edges {
1010            Some(n) => (super::report::SaturationSignal::CodeCoverage, n),
1011            None => (
1012                super::report::SaturationSignal::AssertionCoverage,
1013                reached_sometimes.len(),
1014            ),
1015        };
1016
1017        if iteration_count == 1 {
1018            *prev_signal = current;
1019        } else if current == *prev_signal {
1020            *plateau_count += 1;
1021        } else {
1022            *plateau_count = 0;
1023            *prev_signal = current;
1024        }
1025
1026        let all_reached = all_sometimes_count > 0 && reached_sometimes.len() >= all_sometimes_count;
1027
1028        let edges_total = crate::chaos::exploration_glue::code_coverage_total().unwrap_or_default();
1029        *saturation = Some(super::report::SaturationReport {
1030            signal,
1031            edges_covered: edges.unwrap_or_default(),
1032            edges_total,
1033            sometimes_hit: reached_sometimes.len(),
1034            sometimes_total: all_sometimes_count,
1035            plateau_seeds: *plateau_seeds,
1036        });
1037
1038        tracing::warn!(
1039            "saturation: seed={} sometimes={}/{} signal={:?}={} quiet_seeds={}/{}",
1040            iteration_count,
1041            reached_sometimes.len(),
1042            all_sometimes_count,
1043            signal,
1044            current,
1045            *plateau_count,
1046            plateau_seeds,
1047        );
1048        if *plateau_count >= *plateau_seeds && all_reached {
1049            tracing::info!(
1050                "Saturated after {} seeds: all {} sometimes reached, {:?} stable ({}) for {} seeds",
1051                iteration_count,
1052                all_sometimes_count,
1053                signal,
1054                current,
1055                *plateau_count,
1056            );
1057            return true;
1058        }
1059        false
1060    }
1061
1062    /// Emit a `warn!` when an iteration exceeded the configured threshold.
1063    fn log_slow_seed(seed: u64, wall_time: Duration, threshold: Option<Duration>) {
1064        if let Some(threshold) = threshold
1065            && wall_time > threshold
1066        {
1067            tracing::warn!(
1068                seed,
1069                wall_time_ms = u64::try_from(wall_time.as_millis()).unwrap_or(u64::MAX),
1070                threshold_ms = u64::try_from(threshold.as_millis()).unwrap_or(u64::MAX),
1071                "seed took {:.2}s (threshold: {}s)",
1072                wall_time.as_secs_f64(),
1073                threshold.as_secs(),
1074            );
1075        }
1076    }
1077
1078    /// Emit a milestone `info!` every `progress_milestone` iterations.
1079    fn log_progress_milestone(
1080        progress_milestone: Option<usize>,
1081        iteration_count: usize,
1082        max: usize,
1083    ) {
1084        if let Some(interval) = progress_milestone
1085            && iteration_count.is_multiple_of(interval)
1086        {
1087            let iteration_f64 = u32::try_from(iteration_count).map_or(f64::INFINITY, f64::from);
1088            let max_f64 = u32::try_from(max).map_or(f64::INFINITY, f64::from);
1089            let pct = (iteration_f64 / max_f64) * 100.0;
1090            tracing::info!(
1091                iteration = iteration_count,
1092                total = max,
1093                "[{}/{}] {:.0}% complete",
1094                iteration_count,
1095                max,
1096                pct,
1097            );
1098        }
1099    }
1100
1101    /// Reset per-iteration state: capture buffers, RNG, buggify, and chaos.
1102    fn reset_per_iteration_state(
1103        seed: u64,
1104        swarm_operations: bool,
1105        obs_handle: &SimulationLayerHandle,
1106    ) {
1107        obs_handle.reset_for_seed();
1108        crate::sim::reset_sim_rng();
1109        crate::sim::set_sim_seed(seed);
1110        // Seed the independent config RNG that drives swarm-subset decisions.
1111        // Runs before `build_sim_for_iteration`, so `swarm_for_seed()` sees it.
1112        crate::sim::set_config_seed(seed);
1113        // Seed the independent select! branch-offset stream and install it as
1114        // moonpool_core::select!'s offset source for this iteration.
1115        crate::sim::set_select_seed(seed);
1116        // Per-seed base for the workload operation-alphabet swarm mask; `None`
1117        // disables masking so workloads see the full alphabet.
1118        crate::sim::set_swarm_op_seed(swarm_operations.then_some(seed));
1119        crate::chaos::reset_always_violations();
1120        // Use moderate probabilities: 50% activation rate, 25% firing rate.
1121        crate::chaos::buggify_init(0.5, 0.25);
1122    }
1123
1124    /// Resolve a process entry into a `ProcessConfig` for the current
1125    /// iteration, sampling the count/tags from the sim RNG (already seeded).
1126    fn resolve_process_config(entry: &ProcessEntry) -> super::orchestrator::ProcessConfig<'_> {
1127        // When a topology is configured it owns the process count (sampled per
1128        // seed); otherwise fall back to the flat `.processes()` count.
1129        let localities = entry
1130            .locality
1131            .as_ref()
1132            .map(LocalityConfig::resolve_topology);
1133        let count = localities
1134            .as_ref()
1135            .map_or_else(|| entry.count.resolve(), Vec::len);
1136
1137        let mut registry = crate::runner::tags::TagRegistry::new();
1138        let mut machine_registry = MachineRegistry::new();
1139        let mut ips = Vec::with_capacity(count);
1140        let mut info = Vec::with_capacity(count);
1141        let base_name = &entry.name;
1142        for i in 0..count {
1143            let ip = format!("10.0.1.{}", i + 1);
1144            let ip_addr: std::net::IpAddr = ip.parse().expect("valid process IP");
1145            let tags = entry.tags.resolve(i);
1146            registry.register(ip_addr, tags);
1147            if let Some(localities) = &localities {
1148                machine_registry.register(ip_addr, localities[i].clone());
1149            }
1150            ips.push(ip.clone());
1151            let name = if count == 1 {
1152                base_name.clone()
1153            } else {
1154                format!("{base_name}-{i}")
1155            };
1156            info.push((name, ip));
1157        }
1158        super::orchestrator::ProcessConfig {
1159            factory: &*entry.factory,
1160            info,
1161            ips,
1162            tag_registry: registry,
1163            machine_registry,
1164        }
1165    }
1166
1167    /// Initialise the assertion region (heap, or `MAP_SHARED` + explorer), and
1168    /// activate exploration when a config is present.
1169    fn init_assertions_and_exploration(
1170        exploration_config: Option<&crate::chaos::exploration_glue::ExplorationConfig>,
1171    ) {
1172        crate::chaos::exploration_glue::init_assertion_region();
1173        let _ = exploration_config;
1174        #[cfg(feature = "exploration")]
1175        if let Some(config) = exploration_config {
1176            moonpool_explorer::set_rng_hooks(crate::sim::rng_call_count, |seed| {
1177                crate::sim::set_sim_seed(seed);
1178                crate::sim::reset_rng_call_count();
1179            });
1180            if let Err(e) = moonpool_explorer::init(config) {
1181                tracing::error!("Failed to initialize exploration: {}", e);
1182            }
1183        }
1184    }
1185
1186    /// Build the final `ExplorationReport` from the running totals collected
1187    /// across iterations.
1188    #[cfg(feature = "exploration")]
1189    fn build_exploration_report(
1190        total_timelines: u64,
1191        total_fork_points: u64,
1192        total_bugs: u64,
1193        bug_recipes: Vec<super::report::BugRecipe>,
1194        converged: bool,
1195        per_seed_timelines: Vec<u64>,
1196    ) -> super::report::ExplorationReport {
1197        let final_stats = moonpool_explorer::exploration_stats();
1198        let coverage_bits = moonpool_explorer::explored_map_bits_set().unwrap_or(0);
1199        super::report::ExplorationReport {
1200            total_timelines,
1201            fork_points: total_fork_points,
1202            bugs_found: total_bugs,
1203            bug_recipes,
1204            energy_remaining: final_stats.as_ref().map_or(0, |s| s.global_energy),
1205            realloc_pool_remaining: final_stats.as_ref().map_or(0, |s| s.realloc_pool_remaining),
1206            coverage_bits,
1207            coverage_total: u32::try_from(moonpool_explorer::coverage::COVERAGE_MAP_SIZE * 8)
1208                .expect("coverage map size fits in u32"),
1209            sancov_edges_total: final_stats.as_ref().map_or(0, |s| s.sancov_edges_total),
1210            sancov_edges_covered: final_stats.as_ref().map_or(0, |s| s.sancov_edges_covered),
1211            converged,
1212            per_seed_timelines,
1213        }
1214    }
1215
1216    /// Read the explorer's per-seed exploration stats and accumulate into
1217    /// the totals + per-seed timelines arrays. Captures any new bug recipe
1218    /// produced this seed.
1219    #[cfg(feature = "exploration")]
1220    fn accumulate_exploration_stats(
1221        seed: u64,
1222        per_seed_timelines: &mut Vec<u64>,
1223        total_timelines: &mut u64,
1224        total_fork_points: &mut u64,
1225        total_bugs: &mut u64,
1226        bug_recipes: &mut Vec<super::report::BugRecipe>,
1227    ) {
1228        if let Some(stats) = moonpool_explorer::exploration_stats() {
1229            per_seed_timelines.push(stats.total_timelines);
1230            *total_timelines += stats.total_timelines;
1231            *total_fork_points += stats.fork_points;
1232            *total_bugs += stats.bug_found;
1233        } else {
1234            per_seed_timelines.push(0);
1235        }
1236        if let Some(recipe) = moonpool_explorer::bug_recipe() {
1237            bug_recipes.push(super::report::BugRecipe { seed, recipe });
1238        }
1239    }
1240
1241    /// Scan all assertion slots from shared memory: insert the messages
1242    /// of every "passed" Sometimes/Reachable slot into `reached`, log a
1243    /// warning for every still-unreached slot, and return the count of
1244    /// unique Sometimes/Reachable message strings observed.
1245    fn scan_assertion_slots(reached: &mut std::collections::HashSet<String>) -> usize {
1246        let slots = moonpool_assertions::assertion_read_all();
1247        for slot in &slots {
1248            if let Some(kind) = moonpool_assertions::AssertKind::from_u8(slot.kind)
1249                && matches!(
1250                    kind,
1251                    moonpool_assertions::AssertKind::Sometimes
1252                        | moonpool_assertions::AssertKind::Reachable
1253                )
1254            {
1255                if slot.pass_count > 0 {
1256                    reached.insert(slot.msg.clone());
1257                } else if !reached.contains(&slot.msg) {
1258                    tracing::warn!(
1259                        "UNREACHED slot: kind={:?} msg={:?} pass={} fail={}",
1260                        kind,
1261                        slot.msg,
1262                        slot.pass_count,
1263                        slot.fail_count
1264                    );
1265                }
1266            }
1267        }
1268        slots
1269            .iter()
1270            .filter(|s| {
1271                moonpool_assertions::AssertKind::from_u8(s.kind).is_some_and(|k| {
1272                    matches!(
1273                        k,
1274                        moonpool_assertions::AssertKind::Sometimes
1275                            | moonpool_assertions::AssertKind::Reachable
1276                    )
1277                })
1278            })
1279            .map(|s| s.msg.clone())
1280            .collect::<std::collections::HashSet<_>>()
1281            .len()
1282    }
1283
1284    /// Build the empty report returned when no workloads are registered.
1285    fn empty_report() -> SimulationReport {
1286        SimulationReport {
1287            iterations: 0,
1288            successful_runs: 0,
1289            failed_runs: 0,
1290            metrics: SimulationMetrics::default(),
1291            individual_metrics: Vec::new(),
1292            seeds_used: Vec::new(),
1293            seeds_failing: Vec::new(),
1294            assertion_results: HashMap::new(),
1295            assertion_violations: Vec::new(),
1296            coverage_violations: Vec::new(),
1297            exploration: None,
1298            assertion_details: Vec::new(),
1299            bucket_summaries: Vec::new(),
1300            convergence_timeout: false,
1301            saturation: None,
1302        }
1303    }
1304
1305    #[instrument(skip_all)]
1306    /// Run the simulation and generate a report.
1307    ///
1308    /// Creates a fresh deterministic [`Executor`](crate::executor::Executor)
1309    /// per iteration for full isolation — all tasks are killed when the
1310    /// executor is dropped at iteration end.
1311    ///
1312    /// # Panics
1313    ///
1314    /// Panics if a simulation invariant fails or a workload panics.
1315    pub fn run(mut self) -> SimulationReport {
1316        if self.entries.is_empty() {
1317            return Self::empty_report();
1318        }
1319
1320        // Uninstall the select! offset override on every exit path (normal,
1321        // early return, panic): without this, the seeded source installed by
1322        // set_select_seed would leak past run() and later selects on this
1323        // thread would silently keep drawing from the stale sim stream
1324        // instead of the documented entropy fallback.
1325        struct SelectOverrideReset;
1326        impl Drop for SelectOverrideReset {
1327            fn drop(&mut self) {
1328                crate::sim::reset_select_rng();
1329            }
1330        }
1331        let _select_reset = SelectOverrideReset;
1332
1333        // Install the observability layer once for the entire run. The guard
1334        // is dropped when run() returns, restoring the previous subscriber.
1335        // All registered invariants live on the layer handle.
1336        let layer = SimulationLayer::new();
1337        let (obs_handle, _obs_guard) = layer.install();
1338        for inv in self.invariants.drain(..) {
1339            obs_handle.register(inv);
1340        }
1341
1342        Self::init_assertions_and_exploration(self.exploration_config.as_ref());
1343
1344        let mut state = RunState::new(&self);
1345
1346        while state.iteration_manager.should_continue() {
1347            if let Some(report) = self.execute_iteration(&mut state, &obs_handle) {
1348                return report;
1349            }
1350            if state.converged {
1351                break;
1352            }
1353        }
1354
1355        Self::build_final_report(
1356            state.metrics_collector,
1357            &state.iteration_manager,
1358            self.exploration_config.as_ref(),
1359            &self.iteration_control,
1360            &FinalReportInputs {
1361                converged: state.converged,
1362                saturation: state.saturation,
1363                #[cfg(feature = "exploration")]
1364                total_exploration_timelines: state.total_exploration_timelines,
1365                #[cfg(feature = "exploration")]
1366                total_exploration_fork_points: state.total_exploration_fork_points,
1367                #[cfg(feature = "exploration")]
1368                total_exploration_bugs: state.total_exploration_bugs,
1369                #[cfg(feature = "exploration")]
1370                bug_recipes: state.bug_recipes,
1371                #[cfg(feature = "exploration")]
1372                per_seed_timelines: state.per_seed_timelines,
1373            },
1374        )
1375    }
1376
1377    /// Execute one iteration of the run loop. Returns `Some(report)` when the
1378    /// loop must terminate early (e.g. orchestrator deadlock).
1379    fn execute_iteration(
1380        &mut self,
1381        state: &mut RunState,
1382        obs_handle: &SimulationLayerHandle,
1383    ) -> Option<SimulationReport> {
1384        let seed = state.iteration_manager.next_iteration();
1385        let iteration_count = state.iteration_manager.current_iteration();
1386
1387        self.prepare_iteration(obs_handle, seed, iteration_count);
1388
1389        let (orchestration_result, start_time) =
1390            self.run_orchestrator_for_iteration(state, obs_handle, seed, iteration_count);
1391
1392        if let Err(report) = self.handle_orchestration_result(
1393            state,
1394            orchestration_result,
1395            seed,
1396            iteration_count,
1397            start_time,
1398        ) {
1399            return Some(*report);
1400        }
1401
1402        self.finish_iteration(state, seed, iteration_count);
1403        None
1404    }
1405
1406    /// Run all per-iteration setup steps before the orchestrator starts:
1407    /// prepare-next-seed, user hooks, reset state.
1408    fn prepare_iteration(
1409        &mut self,
1410        obs_handle: &SimulationLayerHandle,
1411        seed: u64,
1412        iteration_count: usize,
1413    ) {
1414        // Preserve assertion data across iterations so the final report
1415        // reflects all seeds, not just the last one. For exploration runs,
1416        // prepare_next_seed() also does a selective reset of coverage state.
1417        if iteration_count > 1 {
1418            #[cfg(feature = "exploration")]
1419            if let Some(ref config) = self.exploration_config {
1420                moonpool_explorer::prepare_next_seed(config.global_energy);
1421            }
1422            crate::chaos::assertions::skip_next_assertion_reset();
1423        }
1424
1425        for hook in &mut self.before_iteration_hooks {
1426            hook();
1427        }
1428
1429        Self::reset_per_iteration_state(seed, self.swarm_operations, obs_handle);
1430    }
1431
1432    /// Resolve workload entries, build the per-iteration sim/fault-injectors,
1433    /// and drive the orchestrator. Stashes `return_map` in `state` for the
1434    /// subsequent result-handling step. Returns the orchestration outcome and
1435    /// the wall-clock start time of the orchestrator call (used for slow-seed
1436    /// logging).
1437    fn run_orchestrator_for_iteration(
1438        &mut self,
1439        state: &mut RunState,
1440        obs_handle: &SimulationLayerHandle,
1441        seed: u64,
1442        iteration_count: usize,
1443    ) -> (OrchestrationOutcome, Instant) {
1444        let ResolvedEntries {
1445            workloads,
1446            return_map,
1447            client_info,
1448        } = self.resolve_entries();
1449        state.pending_return_map = return_map;
1450
1451        let workload_info: Vec<(String, String)> = workloads
1452            .iter()
1453            .enumerate()
1454            .map(|(i, w)| (w.name().to_string(), format!("10.0.0.{}", i + 1)))
1455            .collect();
1456
1457        let process_config = self
1458            .process_entry
1459            .as_ref()
1460            .map(Self::resolve_process_config);
1461
1462        let sim = Self::build_sim_for_iteration(
1463            self.network_chaos,
1464            self.storage_chaos,
1465            self.buggify_knobs,
1466            seed,
1467        );
1468        let start_time = Instant::now();
1469        // Derive the per-seed attrition regime: `Swarm` draws a fresh reboot regime
1470        // from `CONFIG_RNG` (after the network/storage masks, keeping the draw order
1471        // fixed); `Random` uses the configured weights as written.
1472        let attrition = match (self.attrition.as_ref(), self.attrition_mode) {
1473            (Some(base), ChaosMode::Swarm) => Some(base.swarm_for_seed()),
1474            (Some(base), ChaosMode::Random) => Some(base.clone()),
1475            (None, _) => None,
1476        };
1477        let fault_injectors =
1478            Self::collect_fault_injectors(&mut self.fault_injectors, attrition.as_ref());
1479        let outcome = Self::run_orchestrator_blocking(RunOrchestratorInputs {
1480            seed,
1481            iteration_count,
1482            workloads,
1483            workload_info,
1484            client_info,
1485            process_config,
1486            sim,
1487            fault_injectors,
1488            chaos_duration: self.chaos_duration,
1489            obs_handle: obs_handle.clone(),
1490            run_time_budget: self.run_time_budget,
1491        });
1492        (outcome, start_time)
1493    }
1494
1495    /// Process the orchestration outcome: route the success path back into
1496    /// state, or build an early-exit report on deadlock.
1497    fn handle_orchestration_result(
1498        &mut self,
1499        state: &mut RunState,
1500        result: OrchestrationOutcome,
1501        seed: u64,
1502        iteration_count: usize,
1503        start_time: Instant,
1504    ) -> Result<(), Box<SimulationReport>> {
1505        let max_iterations = state
1506            .iteration_manager
1507            .max_iterations()
1508            .unwrap_or(iteration_count);
1509        let seeds_used_snapshot = state.iteration_manager.seeds_used().to_vec();
1510        match result {
1511            Ok(OrchestrateOutput {
1512                workloads: returned_workloads,
1513                fault_injectors: returned_injectors,
1514                results: all_results,
1515                metrics: sim_metrics,
1516            }) => {
1517                let return_map = std::mem::take(&mut state.pending_return_map);
1518                self.return_entries(returned_workloads, return_map);
1519                self.fault_injectors = returned_injectors;
1520                let wall_time = start_time.elapsed();
1521                state.metrics_collector.record_iteration(
1522                    seed,
1523                    wall_time,
1524                    &all_results,
1525                    crate::chaos::has_always_violations(),
1526                    sim_metrics,
1527                );
1528                Self::log_slow_seed(seed, wall_time, self.seed_warning_timeout);
1529                Self::log_progress_milestone(
1530                    state.progress_milestone,
1531                    iteration_count,
1532                    max_iterations,
1533                );
1534                Ok(())
1535            }
1536            Err((faulty_seeds_from_deadlock, failed_count)) => {
1537                state
1538                    .metrics_collector
1539                    .add_faulty_seeds(faulty_seeds_from_deadlock);
1540                state.metrics_collector.add_failed_runs(failed_count);
1541                let metrics_collector =
1542                    std::mem::replace(&mut state.metrics_collector, MetricsCollector::new());
1543                Err(Box::new(Self::build_early_exit_report(
1544                    metrics_collector,
1545                    iteration_count,
1546                    seeds_used_snapshot,
1547                )))
1548            }
1549        }
1550    }
1551
1552    /// Run all per-iteration cleanup steps after the orchestrator finished:
1553    /// accumulate exploration stats, run the convergence scan, reset buggify.
1554    fn finish_iteration(&self, state: &mut RunState, seed: u64, iteration_count: usize) {
1555        // `seed` is only consumed by the exploration stats accumulation below.
1556        #[cfg(not(feature = "exploration"))]
1557        let _ = seed;
1558        #[cfg(feature = "exploration")]
1559        if self.exploration_config.is_some() {
1560            Self::accumulate_exploration_stats(
1561                seed,
1562                &mut state.per_seed_timelines,
1563                &mut state.total_exploration_timelines,
1564                &mut state.total_exploration_fork_points,
1565                &mut state.total_exploration_bugs,
1566                &mut state.bug_recipes,
1567            );
1568        }
1569
1570        let needs_assertion_scan = matches!(
1571            self.iteration_control,
1572            IterationControl::UntilCoverageStable { .. }
1573        );
1574        if needs_assertion_scan {
1575            let all_sometimes_count = Self::scan_assertion_slots(&mut state.reached_sometimes);
1576            state.converged = Self::check_convergence_or_plateau(ConvergenceState {
1577                iteration_control: &self.iteration_control,
1578                iteration_count,
1579                reached_sometimes: &state.reached_sometimes,
1580                all_sometimes_count,
1581                exploration_active: self.exploration_config.is_some(),
1582                prev_signal: &mut state.prev_signal,
1583                plateau_count: &mut state.plateau_count,
1584                saturation: &mut state.saturation,
1585                already_converged: state.converged,
1586            });
1587        }
1588
1589        crate::chaos::buggify_reset();
1590    }
1591
1592    /// Drain shared-memory state, free it, then build the final report.
1593    fn build_final_report(
1594        metrics_collector: MetricsCollector,
1595        iteration_manager: &IterationManager,
1596        exploration_config: Option<&crate::chaos::exploration_glue::ExplorationConfig>,
1597        iteration_control: &IterationControl,
1598        inputs: &FinalReportInputs,
1599    ) -> SimulationReport {
1600        let converged = inputs.converged;
1601
1602        // 1. Read exploration-specific data (freed by cleanup). Without the
1603        // `exploration` feature there is none — the report's `exploration` field
1604        // is simply `None`, keeping the public report shape identical. The two
1605        // accumulator Vecs are cloned once here (report time only).
1606        #[cfg(feature = "exploration")]
1607        let exploration_report = if exploration_config.is_some() {
1608            Some(Self::build_exploration_report(
1609                inputs.total_exploration_timelines,
1610                inputs.total_exploration_fork_points,
1611                inputs.total_exploration_bugs,
1612                inputs.bug_recipes.clone(),
1613                converged,
1614                inputs.per_seed_timelines.clone(),
1615            ))
1616        } else {
1617            None
1618        };
1619        #[cfg(not(feature = "exploration"))]
1620        let exploration_report: Option<super::report::ExplorationReport> = None;
1621
1622        // 2. Read assertion + bucket data (freed by cleanup/cleanup_assertions).
1623        let assertion_results = crate::chaos::assertion_results();
1624        let (assertion_violations, coverage_violations) =
1625            crate::chaos::validate_assertion_contracts();
1626        let raw_assertion_slots = moonpool_assertions::assertion_read_all();
1627        let raw_each_buckets = moonpool_assertions::each_bucket_read_all();
1628
1629        // 3. Now safe to free all shared memory. Under exploration `cleanup()`
1630        // frees the exploration regions (the assertion table persists, as before);
1631        // otherwise free the assertion region directly.
1632        let did_exploration_cleanup = {
1633            #[cfg(feature = "exploration")]
1634            {
1635                if exploration_config.is_some() {
1636                    moonpool_explorer::cleanup();
1637                    true
1638                } else {
1639                    false
1640                }
1641            }
1642            #[cfg(not(feature = "exploration"))]
1643            {
1644                let _ = exploration_config;
1645                false
1646            }
1647        };
1648        if !did_exploration_cleanup {
1649            crate::chaos::exploration_glue::cleanup_assertion_region();
1650        }
1651
1652        let assertion_details = build_assertion_details(&raw_assertion_slots);
1653        let bucket_summaries = build_bucket_summaries(&raw_each_buckets);
1654        let iteration_count = iteration_manager.current_iteration();
1655
1656        // Detect saturation timeout: the cap was hit without saturating.
1657        let convergence_timeout = matches!(
1658            iteration_control,
1659            IterationControl::UntilCoverageStable { .. }
1660        ) && !converged;
1661
1662        crate::chaos::buggify_reset();
1663
1664        metrics_collector.generate_report(GenerateReportInputs {
1665            iteration_count,
1666            seeds_used: iteration_manager.seeds_used().to_vec(),
1667            assertion_results,
1668            assertion_violations,
1669            coverage_violations,
1670            exploration: exploration_report,
1671            assertion_details,
1672            bucket_summaries,
1673            convergence_timeout,
1674            saturation: inputs.saturation.clone(),
1675        })
1676    }
1677}
1678
1679/// Build [`AssertionDetail`] vec from raw assertion slot snapshots.
1680fn build_assertion_details(
1681    slots: &[moonpool_assertions::AssertionSlotSnapshot],
1682) -> Vec<super::report::AssertionDetail> {
1683    use super::report::{AssertionDetail, AssertionStatus};
1684    use moonpool_assertions::AssertKind;
1685
1686    slots
1687        .iter()
1688        .filter_map(|slot| {
1689            let kind = AssertKind::from_u8(slot.kind)?;
1690            let total = slot.pass_count.saturating_add(slot.fail_count);
1691
1692            // Skip unvisited assertions
1693            if total == 0 && slot.frontier == 0 {
1694                return None;
1695            }
1696
1697            let status = match kind {
1698                AssertKind::Always
1699                | AssertKind::AlwaysOrUnreachable
1700                | AssertKind::NumericAlways => {
1701                    if slot.fail_count > 0 {
1702                        AssertionStatus::Fail
1703                    } else {
1704                        AssertionStatus::Pass
1705                    }
1706                }
1707                AssertKind::Sometimes | AssertKind::NumericSometimes | AssertKind::Reachable => {
1708                    if slot.pass_count > 0 {
1709                        AssertionStatus::Pass
1710                    } else {
1711                        AssertionStatus::Miss
1712                    }
1713                }
1714                AssertKind::Unreachable => {
1715                    if slot.pass_count > 0 {
1716                        AssertionStatus::Fail
1717                    } else {
1718                        AssertionStatus::Pass
1719                    }
1720                }
1721                AssertKind::BooleanSometimesAll => {
1722                    if slot.frontier > 0 {
1723                        AssertionStatus::Pass
1724                    } else {
1725                        AssertionStatus::Miss
1726                    }
1727                }
1728            };
1729
1730            Some(AssertionDetail {
1731                msg: slot.msg.clone(),
1732                kind,
1733                pass_count: slot.pass_count,
1734                fail_count: slot.fail_count,
1735                watermark: slot.watermark,
1736                frontier: slot.frontier,
1737                status,
1738            })
1739        })
1740        .collect()
1741}
1742
1743/// Build [`BucketSiteSummary`] vec by grouping [`EachBucket`]s by site message.
1744fn build_bucket_summaries(
1745    buckets: &[moonpool_assertions::EachBucket],
1746) -> Vec<super::report::BucketSiteSummary> {
1747    use super::report::BucketSiteSummary;
1748    use std::collections::HashMap;
1749
1750    let mut sites: HashMap<u32, BucketSiteSummary> = HashMap::new();
1751
1752    for bucket in buckets {
1753        let entry = sites
1754            .entry(bucket.site_hash)
1755            .or_insert_with(|| BucketSiteSummary {
1756                msg: bucket.msg_str().to_string(),
1757                buckets_discovered: 0,
1758                total_hits: 0,
1759            });
1760
1761        entry.buckets_discovered += 1;
1762        entry.total_hits += u64::from(bucket.pass_count);
1763    }
1764
1765    let mut summaries: Vec<_> = sites.into_values().collect();
1766    summaries.sort_by_key(|s| std::cmp::Reverse(s.total_hits));
1767    summaries
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use super::*;
1773    use async_trait::async_trait;
1774    use moonpool_core::RandomProvider;
1775
1776    use crate::SimulationResult;
1777    use crate::runner::context::SimContext;
1778
1779    struct BasicWorkload;
1780
1781    #[async_trait]
1782    impl Workload for BasicWorkload {
1783        fn name(&self) -> &'static str {
1784            "test_workload"
1785        }
1786
1787        async fn run(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
1788            Ok(())
1789        }
1790    }
1791
1792    #[test]
1793    fn test_simulation_builder_basic() {
1794        let report = SimulationBuilder::new()
1795            .workload(BasicWorkload)
1796            .set_iterations(3)
1797            .set_debug_seeds(vec![1, 2, 3])
1798            .run();
1799
1800        assert_eq!(report.iterations, 3);
1801        assert_eq!(report.successful_runs, 3);
1802        assert_eq!(report.failed_runs, 0);
1803        assert!((report.success_rate() - 100.0).abs() < f64::EPSILON);
1804        assert_eq!(report.seeds_used, vec![1, 2, 3]);
1805    }
1806
1807    struct FailingWorkload;
1808
1809    #[async_trait]
1810    impl Workload for FailingWorkload {
1811        fn name(&self) -> &'static str {
1812            "failing_workload"
1813        }
1814
1815        async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
1816            // Deterministic: fail if first random number is even
1817            let random_num: u32 = ctx.random().random_range(0..100);
1818            if random_num.is_multiple_of(2) {
1819                return Err(crate::SimulationError::InvalidState(
1820                    "Test failure".to_string(),
1821                ));
1822            }
1823            Ok(())
1824        }
1825    }
1826
1827    #[test]
1828    fn test_simulation_builder_with_failures() {
1829        // Pinned seeds: without them, iteration seeds derive from the wall
1830        // clock and this test demands both outcomes across 10 fair coin
1831        // flips, a ~0.2% spontaneous failure rate. Each seed's outcome is a
1832        // pure function of the seed, so pinning makes it deterministic.
1833        let report = SimulationBuilder::new()
1834            .workload(FailingWorkload)
1835            .set_debug_seeds((1..=10).collect())
1836            .set_iterations(10)
1837            .run();
1838
1839        assert_eq!(report.iterations, 10);
1840        assert_eq!(
1841            report.successful_runs + report.failed_runs,
1842            10,
1843            "all iterations should be accounted for"
1844        );
1845        assert!(
1846            report.failed_runs > 0,
1847            "expected at least one failure across 10 seeds"
1848        );
1849        assert!(
1850            report.successful_runs > 0,
1851            "expected at least one success across 10 seeds"
1852        );
1853    }
1854}