Skip to main content

hydro_lang/sim/
flow.rs

1//! Entrypoint for compiling and running Hydro simulations.
2
3use std::cell::RefCell;
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::panic::RefUnwindSafe;
6use std::rc::Rc;
7
8use dfir_lang::graph::{DfirGraph, FlatGraphBuilder, FlatGraphBuilderOutput};
9use libloading::Library;
10use slotmap::{SecondaryMap, SparseSecondaryMap};
11
12use super::builder::SimBuilder;
13use super::compiled::{CompiledSim, CompiledSimInstance};
14use super::graph::{SimDeploy, SimExternal, SimNode, compile_sim, create_sim_graph_trybuild};
15use crate::compile::builder::StmtId;
16use crate::compile::ir::HydroRoot;
17use crate::location::LocationKey;
18use crate::location::dynamic::LocationId;
19use crate::prelude::Cluster;
20use crate::sim::graph::SimExternalPortRegistry;
21use crate::staging_util::Invariant;
22
23/// A not-yet-compiled simulator for a Hydro program.
24pub struct SimFlow<'a> {
25    pub(crate) ir: Vec<HydroRoot>,
26
27    /// [`SimNode`] for each Process.
28    pub(crate) processes: SparseSecondaryMap<LocationKey, SimNode>,
29    /// [`SimNode`] for each Cluster.
30    pub(crate) clusters: SparseSecondaryMap<LocationKey, SimNode>,
31    /// [`SimExternal`] for each External.
32    pub(crate) externals: SparseSecondaryMap<LocationKey, SimExternal>,
33
34    /// Max size of each cluster.
35    pub(crate) cluster_max_sizes: SparseSecondaryMap<LocationKey, usize>,
36    /// Handle to state handling `external`s' ports.
37    pub(crate) externals_port_registry: Rc<RefCell<SimExternalPortRegistry>>,
38
39    /// The program version each location belongs to (all `0` for a single-version flow). Every
40    /// location has an entry.
41    pub(crate) location_version: SecondaryMap<LocationKey, u32>,
42
43    /// Maps each location to the root key of its cross-version correspondence group: version 0 of
44    /// the same logical location. Every location has an entry (its own key unless it is a
45    /// `next_version` successor); populated eagerly at location creation.
46    pub(crate) location_version_group_root: SecondaryMap<LocationKey, LocationKey>,
47
48    /// When true, the simulator only tests safety properties (not liveness).
49    pub(crate) test_safety_only: bool,
50
51    /// When true, consistency assertions are skipped (treated as identity no-ops).
52    /// When false (default), encountering a consistency assertion panics because
53    /// validating consistency assertions is not yet supported in the simulator.
54    pub(crate) skip_consistency_assertions: bool,
55
56    /// Number of iterations to use for fuzzing, defaults to 8192
57    pub(crate) unit_test_fuzz_iterations: usize,
58
59    pub(crate) _phantom: Invariant<'a>,
60}
61
62impl<'a> SimFlow<'a> {
63    /// Sets the maximum size of the given cluster in the simulation.
64    #[must_use]
65    pub fn with_cluster_size<C>(mut self, cluster: &Cluster<'a, C>, max_size: usize) -> Self {
66        self.cluster_max_sizes.insert(cluster.key, max_size);
67        self
68    }
69
70    /// Opts in to safety-only testing, which is required when using
71    /// [`lossy_delayed_forever`](crate::networking::NetworkingConfig::lossy_delayed_forever)
72    /// networking.
73    ///
74    /// The simulator models dropped messages as indefinitely delayed, which means
75    /// it only tests safety properties—not liveness—since messages may never arrive.
76    /// Calling this method acknowledges that the simulation will not verify that the
77    /// program eventually makes progress.
78    #[must_use]
79    pub fn test_safety_only(mut self) -> Self {
80        self.test_safety_only = true;
81        self
82    }
83
84    /// Opts in to skipping consistency assertions. When enabled, `assert_is_consistent`
85    /// nodes are treated as identity no-ops in the simulator. When disabled (the default),
86    /// encountering a consistency assertion will panic because validating consistency
87    /// assertions is not yet supported in the simulator.
88    #[must_use]
89    pub fn skip_consistency_assertions(mut self) -> Self {
90        self.skip_consistency_assertions = true;
91        self
92    }
93
94    /// Sets the number of fuzz iterations for this test. Overrides the
95    /// the default value of 8192
96    #[must_use]
97    pub fn unit_test_fuzz_iterations(mut self, iterations: usize) -> Self {
98        self.unit_test_fuzz_iterations = iterations;
99        self
100    }
101
102    /// Executes the given closure with a single instance of the compiled simulation.
103    pub fn with_instance<T>(self, thunk: impl FnOnce(CompiledSimInstance<'_>) -> T) -> T {
104        self.compiled().with_instance(thunk)
105    }
106
107    /// Uses a fuzzing strategy to explore possible executions of the simulation. The provided
108    /// closure will be repeatedly executed with instances of the Hydro program where the
109    /// batching boundaries, order of messages, and retries are varied.
110    ///
111    /// During development, you should run the test that invokes this function with the `cargo sim`
112    /// command, which will use `libfuzzer` to intelligently explore the execution space. If a
113    /// failure is found, a minimized test case will be produced in a `sim-failures` directory.
114    /// When running the test with `cargo test` (such as in CI), if a reproducer is found it will
115    /// be executed, and if no reproducer is found a small number of random executions will be
116    /// performed.
117    pub fn fuzz(self, thunk: impl AsyncFnMut() + RefUnwindSafe) {
118        self.compiled().fuzz(thunk)
119    }
120
121    /// Exhaustively searches all possible executions of the simulation. The provided
122    /// closure will be repeatedly executed with instances of the Hydro program where the
123    /// batching boundaries, order of messages, and retries are varied.
124    ///
125    /// Exhaustive searching is feasible when the inputs to the Hydro program are finite and there
126    /// are no dataflow loops that generate infinite messages. Exhaustive searching provides a
127    /// stronger guarantee of correctness than fuzzing, but may take a long time to complete.
128    /// Because no fuzzer is involved, you can run exhaustive tests with `cargo test`.
129    ///
130    /// Returns the number of distinct executions explored.
131    pub fn exhaustive(self, thunk: impl AsyncFnMut() + RefUnwindSafe) -> usize {
132        self.compiled().exhaustive(thunk)
133    }
134
135    /// Runs the test body against exactly **one** execution of the program, with no fuzzer
136    /// involved anywhere. Requires every unsafe operator that receives data to be bound to
137    /// a sim hook and scripted; see [`crate::sim::compiled::CompiledSim::deterministic`].
138    pub fn deterministic(self, thunk: impl AsyncFnOnce() + RefUnwindSafe) {
139        self.compiled().deterministic(thunk)
140    }
141
142    /// Compiles the simulation into a dynamically loadable library, and returns a handle to it.
143    pub fn compiled(mut self) -> CompiledSim {
144        use dfir_lang::graph::{eliminate_extra_unions_tees, partition_graph};
145
146        let compiled_span = tracing::debug_span!(target: "hydro_build", "sim_compiled").entered();
147        let flow_build_span = tracing::debug_span!(target: "hydro_build", "flow_build").entered();
148
149        let is_multi_version = self.location_version.values().any(|&v| v > 0);
150
151        let mut sim_emit = SimBuilder {
152            process_graphs: BTreeMap::new(),
153            cluster_graphs: BTreeMap::new(),
154            process_tick_dfirs: BTreeMap::new(),
155            cluster_tick_dfirs: BTreeMap::new(),
156            extra_stmts_global: vec![],
157            extra_stmts_cluster: BTreeMap::new(),
158            next_hoff_id: crate::Counter::default(),
159            test_safety_only: self.test_safety_only,
160            skip_consistency_assertions: self.skip_consistency_assertions,
161            channel_tables: BTreeMap::new(),
162            bound_sim_hooks: HashMap::new(),
163        };
164
165        // Ensure the default (0) external is always present.
166        self.externals.insert(
167            LocationKey::FIRST,
168            SimExternal {
169                shared_inner: self.externals_port_registry.clone(),
170            },
171        );
172
173        let mut seen_tees_instantiate: HashMap<_, _> = HashMap::new();
174        let mut seen_cluster_members = HashSet::new();
175        self.ir.iter_mut().for_each(|leaf| {
176            leaf.compile_network::<SimDeploy>(
177                &mut SparseSecondaryMap::new(),
178                &mut seen_tees_instantiate,
179                &mut seen_cluster_members,
180                &self.processes,
181                &self.clusters,
182                &self.externals,
183                &mut (),
184            );
185        });
186
187        if is_multi_version {
188            super::versioned_network::splice_versioned_networks(
189                &mut self.ir,
190                &self.location_version_group_root,
191                &self.location_version,
192            );
193        }
194
195        let mut seen_tees = HashMap::new();
196        let mut built_tees = HashMap::new();
197        let mut next_stmt_id = crate::Counter::<StmtId>::default();
198        let mut fold_hooked_idents = HashSet::new();
199        for leaf in &mut self.ir {
200            leaf.emit(
201                &mut sim_emit,
202                &mut seen_tees,
203                &mut built_tees,
204                &mut next_stmt_id,
205                &mut fold_hooked_idents,
206            );
207        }
208
209        fn build_graphs(
210            graphs: BTreeMap<LocationId, FlatGraphBuilder>,
211        ) -> BTreeMap<LocationId, DfirGraph> {
212            graphs
213                .into_iter()
214                .map(|(l, g)| {
215                    let FlatGraphBuilderOutput { mut flat_graph, .. } =
216                        g.build().expect("Failed to build DFIR flat graph.");
217                    eliminate_extra_unions_tees(&mut flat_graph);
218                    let partitioned = partition_graph(flat_graph).unwrap_or_else(|err| {
219                        panic!(
220                            "Failed to partition DFIR graph for location {l:?}: {}",
221                            err.diagnostic
222                        )
223                    });
224                    (l, partitioned)
225                })
226                .collect()
227        }
228
229        let process_graphs = build_graphs(sim_emit.process_graphs);
230        let cluster_graphs = build_graphs(sim_emit.cluster_graphs);
231        let process_tick_graphs = build_graphs(sim_emit.process_tick_dfirs);
232        let cluster_tick_graphs = build_graphs(sim_emit.cluster_tick_dfirs);
233
234        #[expect(
235            clippy::disallowed_methods,
236            reason = "nondeterministic iteration order, fine for checks"
237        )]
238        for c in self.clusters.keys() {
239            assert!(
240                self.cluster_max_sizes.contains_key(c),
241                "Cluster {:?} missing max size; call with_cluster_size() before compiled()",
242                c
243            );
244        }
245
246        let (cluster_max_sizes, cluster_member_ids) = self.cluster_sizing();
247        drop(flow_build_span);
248
249        let (bin, trybuild) = create_sim_graph_trybuild(
250            process_graphs,
251            cluster_graphs,
252            cluster_max_sizes,
253            cluster_member_ids,
254            process_tick_graphs,
255            cluster_tick_graphs,
256            sim_emit.extra_stmts_global,
257            sim_emit.extra_stmts_cluster,
258        );
259
260        let out = compile_sim(bin, trybuild).unwrap();
261        let lib = {
262            let _span = tracing::debug_span!(target: "hydro_build", "load_dylib").entered();
263            unsafe { Library::new(&*out).unwrap() }
264        };
265        drop(compiled_span);
266
267        CompiledSim {
268            _path: out,
269            lib,
270            externals_port_registry: self.externals_port_registry.take(),
271            unit_test_fuzz_iterations: self.unit_test_fuzz_iterations,
272        }
273    }
274
275    /// Computes each cluster's merged size and the global member-id slice it constructs.
276    ///
277    /// Corresponding clusters (a [`next_version`](crate::location::Cluster::next_version) chain)
278    /// share a group key; the merged size for a group is the sum of its per-version sizes, and each
279    /// version gets a contiguous member-id slice assigned in version order. A single-version
280    /// cluster is the degenerate case: its own group, one version, slice `0..size`.
281    fn cluster_sizing(
282        &self,
283    ) -> (
284        SparseSecondaryMap<LocationKey, usize>,
285        BTreeMap<LocationId, Vec<u32>>,
286    ) {
287        // Group corresponding clusters by their shared group root, recording each version's size.
288        let mut sizes_by_group_root: BTreeMap<LocationKey, BTreeMap<u32, usize>> = BTreeMap::new();
289        #[expect(
290            clippy::disallowed_methods,
291            reason = "each cluster key is unique; iteration order does not affect the result"
292        )]
293        for key in self.clusters.keys() {
294            let group_root = self.location_version_group_root[key];
295            let version = self.location_version[key];
296            let size = *self.cluster_max_sizes.get(key).unwrap_or_else(|| {
297                panic!(
298                    "cluster {key:?} missing max size; `compiled()` asserts every cluster has one \
299                     before calling `cluster_sizing`"
300                )
301            });
302            let prev = sizes_by_group_root
303                .entry(group_root)
304                .or_default()
305                .insert(version, size);
306            assert!(
307                prev.is_none(),
308                "multi-version simulation has two corresponding clusters at the same version; \
309                 each `next_version()` call must advance to a distinct version"
310            );
311        }
312
313        // Each cluster location gets the merged total size (so its membership lists the union) and
314        // its own contiguous slice of the global member-id range, assigned in version order.
315        let mut cluster_sizes: SparseSecondaryMap<LocationKey, usize> = SparseSecondaryMap::new();
316        let mut cluster_member_ids: BTreeMap<LocationId, Vec<u32>> = BTreeMap::new();
317        #[expect(
318            clippy::disallowed_methods,
319            reason = "each cluster key is unique; iteration order does not affect the result"
320        )]
321        for key in self.clusters.keys() {
322            let group_root = self.location_version_group_root[key];
323            let version = self.location_version[key];
324            let per_version = &sizes_by_group_root[&group_root];
325            let merged_total: usize = per_version.values().sum();
326            let offset: u32 = per_version.range(..version).map(|(_, &n)| n as u32).sum();
327            let size = *per_version
328                .get(&version)
329                .expect("every (group, version) was recorded by the first pass above")
330                as u32;
331            cluster_sizes.insert(key, merged_total);
332            cluster_member_ids.insert(LocationId::Cluster(key), (offset..offset + size).collect());
333        }
334
335        (cluster_sizes, cluster_member_ids)
336    }
337}