moonpool_sim/sim/rng.rs
1//! Thread-local random number generation for simulation.
2//!
3//! This module provides deterministic randomness through thread-local storage,
4//! enabling clean API design without passing RNG through the simulation state.
5//! Each thread maintains its own RNG state, ensuring deterministic behavior
6//! within each simulation run while supporting parallel test execution.
7
8use rand::SeedableRng;
9use rand::{
10 RngExt,
11 distr::{Distribution, StandardUniform, uniform::SampleUniform},
12};
13use rand_chacha::ChaCha8Rng;
14use std::cell::{Cell, RefCell};
15use std::collections::VecDeque;
16
17thread_local! {
18 /// Thread-local random number generator for simulation.
19 ///
20 /// Uses ChaCha8Rng for deterministic, reproducible randomness.
21 /// Each thread maintains independent state for parallel test execution.
22 static SIM_RNG: RefCell<ChaCha8Rng> = RefCell::new(ChaCha8Rng::seed_from_u64(0));
23
24 /// Thread-local storage for the current simulation seed.
25 ///
26 /// This stores the last seed set via [`set_sim_seed`] to enable
27 /// error reporting with seed information.
28 static CURRENT_SEED: RefCell<u64> = const { RefCell::new(0) };
29
30 /// Thread-local counter tracking RNG calls since last reset.
31 ///
32 /// Used by the exploration framework to record fork points and
33 /// enable deterministic replay via breakpoints.
34 static RNG_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
35
36 /// Thread-local queue of RNG breakpoints, sorted by target call count.
37 ///
38 /// Each entry is `(target_count, new_seed)`. When the call count exceeds
39 /// `target_count`, the RNG reseeds with `new_seed` and the count resets to 1.
40 static RNG_BREAKPOINTS: RefCell<VecDeque<(u64, u64)>> = const { RefCell::new(VecDeque::new()) };
41
42 /// Thread-local configuration RNG, independent of [`SIM_RNG`].
43 ///
44 /// Drives swarm-testing subset decisions (which fault families are enabled
45 /// per seed). It deliberately does NOT go through [`pre_sample`], so it has
46 /// no effect on the [`SIM_RNG`] call count or breakpoint replay — config
47 /// decisions can never perturb in-run randomness or fork-explorer replay.
48 static CONFIG_RNG: RefCell<ChaCha8Rng> = RefCell::new(ChaCha8Rng::seed_from_u64(0));
49
50 /// Thread-local per-seed base for workload operation-alphabet swarm masks.
51 ///
52 /// `Some(seed)` when `.swarm_operations()` is enabled for the current
53 /// iteration, `None` otherwise. See [`swarm_op_enabled`] for how it is consumed; like
54 /// [`CONFIG_RNG`] it never touches the [`SIM_RNG`] call count.
55 static SWARM_OP_SEED: Cell<Option<u64>> = const { Cell::new(None) };
56
57 /// Thread-local `select!` branch-offset RNG, independent of [`SIM_RNG`].
58 ///
59 /// Drives the branch start offsets of `moonpool_core::select!` (installed
60 /// via [`set_select_seed`]). Like [`CONFIG_RNG`] it deliberately does NOT go
61 /// through [`pre_sample`], so select polling order can never perturb the
62 /// [`SIM_RNG`] call count or fork-explorer breakpoint replay.
63 static SELECT_RNG: RefCell<ChaCha8Rng> = RefCell::new(ChaCha8Rng::seed_from_u64(0));
64}
65
66/// Salt mixed into the iteration seed before seeding [`CONFIG_RNG`].
67///
68/// Decorrelates config (swarm-subset) decisions from the in-run [`SIM_RNG`]
69/// stream while keeping both fully reproducible from the same iteration seed.
70const CONFIG_RNG_SALT: u64 = 0x6D6F_6F6E_7377_726D; // "moonswrm"
71
72/// Salt mixed into the iteration seed when deriving operation-alphabet swarm
73/// masks.
74///
75/// Distinct from [`CONFIG_RNG_SALT`] so per-seed *operation* subset decisions
76/// are decorrelated from the *fault-family* subset decisions of the same seed.
77const SWARM_OP_SALT: u64 = 0x6F70_6D61_736B_7372; // "opmasksr"
78
79/// Salt mixed into the iteration seed before seeding [`SELECT_RNG`].
80///
81/// Decorrelates `select!` branch start offsets from the in-run [`SIM_RNG`]
82/// stream (and from the other salted streams) while keeping them fully
83/// reproducible from the same iteration seed.
84const SELECT_RNG_SALT: u64 = 0x7365_6C62_726E_6368; // "selbrnch"
85
86/// Increment the RNG call counter and check for breakpoints.
87///
88/// Called before every RNG sample. If the current call count exceeds
89/// a breakpoint's target, reseeds the RNG and resets the counter.
90fn pre_sample() {
91 RNG_CALL_COUNT.with(|c| c.set(c.get() + 1));
92 check_rng_breakpoint();
93}
94
95/// Check and trigger any pending RNG breakpoints.
96///
97/// Pops breakpoints whose target count has been exceeded (using `>`),
98/// reseeding the RNG for each. The count resets to 1 because the
99/// current call is the first call of the new seed segment.
100fn check_rng_breakpoint() {
101 RNG_BREAKPOINTS.with(|bp| {
102 let mut breakpoints = bp.borrow_mut();
103 while let Some(&(target_count, new_seed)) = breakpoints.front() {
104 let count = RNG_CALL_COUNT.with(std::cell::Cell::get);
105 if count > target_count {
106 breakpoints.pop_front();
107 SIM_RNG.with(|rng| {
108 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(new_seed);
109 });
110 CURRENT_SEED.with(|s| {
111 *s.borrow_mut() = new_seed;
112 });
113 RNG_CALL_COUNT.with(|c| c.set(1));
114 } else {
115 break;
116 }
117 }
118 });
119}
120
121/// Generate a random value using the thread-local simulation RNG.
122///
123/// This function provides deterministic randomness based on the seed set
124/// via [`set_sim_seed`]. The same seed will always produce the same sequence
125/// of random values within a single thread.
126///
127/// # Type Parameters
128///
129/// * `T` - The type to generate. Must implement the Standard distribution.
130///
131/// Generate a random value using the thread-local simulation RNG.
132#[must_use]
133pub fn sim_random<T>() -> T
134where
135 StandardUniform: Distribution<T>,
136{
137 pre_sample();
138 SIM_RNG.with(|rng| rng.borrow_mut().sample(StandardUniform))
139}
140
141/// Generate a random value within a specified range using the thread-local simulation RNG.
142///
143/// This function provides deterministic randomness for values within a range.
144/// The same seed will always produce the same sequence of values.
145///
146/// # Type Parameters
147///
148/// * `T` - The type to generate. Must implement `SampleUniform`.
149///
150/// # Parameters
151///
152/// * `range` - The range to sample from (exclusive upper bound).
153///
154/// Generate a random value within a specified range.
155pub fn sim_random_range<T>(range: std::ops::Range<T>) -> T
156where
157 T: SampleUniform + PartialOrd,
158{
159 pre_sample();
160 SIM_RNG.with(|rng| rng.borrow_mut().random_range(range))
161}
162
163/// Generate a random value within the given range, returning the start value if the range is empty.
164///
165/// This is a safe version of [`sim_random_range`] that handles empty ranges gracefully
166/// by returning the start value when start == end.
167///
168/// # Parameters
169///
170/// * `range` - The range to sample from (start..end)
171///
172/// # Returns
173///
174/// A random value within the range, or the start value if the range is empty.
175///
176/// Generate a random value in range or return start value if range is empty.
177pub fn sim_random_range_or_default<T>(range: std::ops::Range<T>) -> T
178where
179 T: SampleUniform + PartialOrd + Clone,
180{
181 if range.start >= range.end {
182 range.start
183 } else {
184 sim_random_range(range)
185 }
186}
187
188/// Set the seed for the thread-local simulation RNG.
189///
190/// This function initializes the thread-local RNG with a specific seed,
191/// ensuring deterministic behavior. The same seed will always produce
192/// the same sequence of random values.
193///
194/// # Parameters
195///
196/// * `seed` - The seed value to use for deterministic randomness.
197///
198/// Set the seed for the thread-local simulation RNG.
199pub fn set_sim_seed(seed: u64) {
200 SIM_RNG.with(|rng| {
201 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(seed);
202 });
203 CURRENT_SEED.with(|current| {
204 *current.borrow_mut() = seed;
205 });
206}
207
208/// Generate a random f64 in the range [0.0, 1.0) using the simulation RNG.
209///
210/// This is a convenience function matching FDB's `deterministicRandom()->random01()`.
211///
212/// # Returns
213///
214/// A random f64 value in [0.0, 1.0).
215#[must_use]
216pub fn sim_random_f64() -> f64 {
217 pre_sample();
218 SIM_RNG.with(|rng| rng.borrow_mut().sample(StandardUniform))
219}
220
221/// Get the current simulation seed.
222///
223/// Returns the seed that was last set via [`set_sim_seed`].
224/// This is useful for error reporting to help reproduce failing test cases.
225///
226/// # Returns
227///
228/// The current simulation seed, or 0 if no seed has been set.
229///
230/// Get the current simulation seed.
231#[must_use]
232pub fn current_sim_seed() -> u64 {
233 CURRENT_SEED.with(|current| *current.borrow())
234}
235
236/// Reset the thread-local simulation RNG to a fresh state.
237///
238/// This function clears any existing RNG state and initializes with entropy.
239/// It should be called before setting a new seed to ensure clean state
240/// between consecutive simulation runs on the same thread.
241///
242/// Reset the thread-local simulation RNG to a fresh state.
243pub fn reset_sim_rng() {
244 SIM_RNG.with(|rng| {
245 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(0);
246 });
247 CURRENT_SEED.with(|current| {
248 *current.borrow_mut() = 0;
249 });
250 RNG_CALL_COUNT.with(|c| c.set(0));
251 RNG_BREAKPOINTS.with(|bp| bp.borrow_mut().clear());
252}
253
254/// Get the current RNG call count.
255///
256/// Returns the number of RNG calls made since the last seed set or reset.
257/// Used by the exploration framework to record fork points.
258#[must_use]
259pub fn rng_call_count() -> u64 {
260 RNG_CALL_COUNT.with(std::cell::Cell::get)
261}
262
263/// Reset the RNG call count to zero.
264///
265/// Used when reseeding to start a new counting segment.
266pub fn reset_rng_call_count() {
267 RNG_CALL_COUNT.with(|c| c.set(0));
268}
269
270/// Set RNG breakpoints for deterministic replay.
271///
272/// Each breakpoint is a `(target_count, new_seed)` pair. When the RNG call
273/// count exceeds `target_count`, the RNG is reseeded with `new_seed` and
274/// the count resets to 1.
275///
276/// Breakpoints must be sorted by `target_count` in ascending order.
277///
278/// # Parameters
279///
280/// * `breakpoints` - Sorted list of (`target_count`, `new_seed`) pairs.
281pub fn set_rng_breakpoints(breakpoints: Vec<(u64, u64)>) {
282 RNG_BREAKPOINTS.with(|bp| {
283 *bp.borrow_mut() = VecDeque::from(breakpoints);
284 });
285}
286
287/// Clear all RNG breakpoints.
288pub fn clear_rng_breakpoints() {
289 RNG_BREAKPOINTS.with(|bp| bp.borrow_mut().clear());
290}
291
292/// Seed the thread-local configuration RNG from an iteration seed.
293///
294/// The seed is mixed with [`CONFIG_RNG_SALT`] so swarm-subset decisions are
295/// decorrelated from [`SIM_RNG`] yet remain reproducible per iteration seed.
296/// Unlike [`set_sim_seed`], this does not touch the call counter or breakpoints.
297pub fn set_config_seed(seed: u64) {
298 CONFIG_RNG.with(|rng| {
299 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(seed ^ CONFIG_RNG_SALT);
300 });
301}
302
303/// Reset the configuration RNG to its initial (seed 0) state.
304pub fn reset_config_rng() {
305 CONFIG_RNG.with(|rng| {
306 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(0);
307 });
308}
309
310/// Seed the thread-local `select!` offset RNG from an iteration seed and
311/// install it as the branch-offset source for `moonpool_core::select!` on
312/// this thread.
313///
314/// The seed is mixed with [`SELECT_RNG_SALT`] so branch start offsets are
315/// decorrelated from [`SIM_RNG`] yet remain reproducible per iteration seed.
316/// Like [`set_config_seed`], this stream is uncounted: select polling order
317/// never perturbs the call counter or fork-explorer breakpoint replay.
318pub fn set_select_seed(seed: u64) {
319 SELECT_RNG.with(|rng| {
320 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(seed ^ SELECT_RNG_SALT);
321 });
322 moonpool_core::select_support::set_select_offset_override(Some(select_offset_from_stream));
323}
324
325/// Reset the `select!` offset RNG and uninstall the override, restoring
326/// moonpool-core's entropy fallback (production behavior).
327pub fn reset_select_rng() {
328 SELECT_RNG.with(|rng| {
329 *rng.borrow_mut() = ChaCha8Rng::seed_from_u64(0);
330 });
331 moonpool_core::select_support::set_select_offset_override(None);
332}
333
334/// The offset source registered with moonpool-core: one uncounted
335/// [`SELECT_RNG`] draw per `select!` execution.
336fn select_offset_from_stream(branches: u32) -> u32 {
337 SELECT_RNG.with(|rng| rng.borrow_mut().random_range(0..branches))
338}
339
340/// Generate a random f64 in `[0.0, 1.0)` using the configuration RNG.
341///
342/// Independent of [`SIM_RNG`]: it does not increment the RNG call count and is
343/// invisible to breakpoint replay.
344#[must_use]
345pub fn config_random_f64() -> f64 {
346 CONFIG_RNG.with(|rng| rng.borrow_mut().sample(StandardUniform))
347}
348
349/// Return `true` with probability `p`, drawn from the configuration RNG.
350///
351/// Used for swarm-subset decisions (e.g. "enable this fault family?"). Always
352/// consumes exactly one [`CONFIG_RNG`] draw regardless of the outcome.
353#[must_use]
354pub fn config_random_bool(p: f64) -> bool {
355 config_random_f64() < p
356}
357
358/// Set the per-seed base for workload operation-alphabet swarm masks.
359///
360/// Called once per iteration by the runner: `Some(seed)` when `.swarm_operations()`
361/// is enabled, `None` otherwise. With `None`, [`swarm_op_enabled`] reports every
362/// operation as enabled (full alphabet — zero behavior change).
363pub fn set_swarm_op_seed(seed: Option<u64>) {
364 SWARM_OP_SEED.with(|s| s.set(seed));
365}
366
367/// 64-bit `SplitMix64` finalizer — a stateless, dependency-free mixing function.
368///
369/// Used to derive per-`(seed, op_id)` swarm decisions without consuming any RNG
370/// stream, so the result is idempotent and order-independent.
371fn splitmix64(mut x: u64) -> u64 {
372 x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
373 x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
374 x ^ (x >> 31)
375}
376
377/// Report whether operation `op_id` is enabled for the current iteration's
378/// operation-alphabet swarm subset.
379///
380/// When swarm is disabled (no [`set_swarm_op_seed`] with `Some`), every
381/// operation is enabled. When enabled, each operation is independently on with
382/// probability 0.5, derived as a **pure function** of `(seed, op_id)` via
383/// [`splitmix64`] — *not* by consuming the [`CONFIG_RNG`] stream. This makes the
384/// decision idempotent and order-independent: a workload may query the mask any
385/// number of times in any order and always sees the same subset, and the
386/// fault-family `CONFIG_RNG` draw sequence is left untouched. Like
387/// [`config_random_bool`], it never touches [`SIM_RNG`], so fork-explorer replay
388/// is unperturbed.
389///
390/// Callers own the empty-subset fallback: if a seed disables every operation in
391/// the alphabet, the workload should fall back to the full alphabet so it always
392/// has something to do.
393#[must_use]
394pub fn swarm_op_enabled(op_id: u8) -> bool {
395 match SWARM_OP_SEED.with(Cell::get) {
396 None => true,
397 Some(seed) => {
398 let mixed = splitmix64(seed ^ SWARM_OP_SALT ^ u64::from(op_id).rotate_left(32));
399 // Top bit gives a clean 50/50 split.
400 mixed & (1 << 63) != 0
401 }
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 /// Assert two f64 values are bit-identical.
410 fn assert_f64_eq(left: f64, right: f64) {
411 assert_eq!(left.to_bits(), right.to_bits(), "{left} != {right}");
412 }
413
414 /// Assert two f64 values are bit-different.
415 fn assert_f64_ne(left: f64, right: f64) {
416 assert_ne!(left.to_bits(), right.to_bits(), "{left} == {right}");
417 }
418
419 #[test]
420 fn test_deterministic_randomness() {
421 // Set seed and generate some values
422 set_sim_seed(42);
423 let value1: f64 = sim_random();
424 let value2: u32 = sim_random();
425 let value3: bool = sim_random();
426
427 // Reset to same seed and verify same sequence
428 set_sim_seed(42);
429 assert_f64_eq(value1, sim_random::<f64>());
430 assert_eq!(value2, sim_random::<u32>());
431 assert_eq!(value3, sim_random::<bool>());
432 }
433
434 #[test]
435 fn test_different_seeds_produce_different_values() {
436 // Generate values with first seed
437 set_sim_seed(1);
438 let value1_seed1: f64 = sim_random();
439 let value2_seed1: f64 = sim_random();
440
441 // Generate values with different seed
442 set_sim_seed(2);
443 let value1_seed2: f64 = sim_random();
444 let value2_seed2: f64 = sim_random();
445
446 // Values should be different
447 assert_f64_ne(value1_seed1, value1_seed2);
448 assert_f64_ne(value2_seed1, value2_seed2);
449 }
450
451 #[test]
452 fn test_sim_random_range() {
453 set_sim_seed(42);
454
455 // Test integer range
456 for _ in 0..100 {
457 let value = sim_random_range(10..20);
458 assert!(value >= 10);
459 assert!(value < 20);
460 }
461
462 // Test f64 range
463 for _ in 0..100 {
464 let value = sim_random_range(0.0..1.0);
465 assert!(value >= 0.0);
466 assert!(value < 1.0);
467 }
468 }
469
470 #[test]
471 fn test_range_determinism() {
472 set_sim_seed(123);
473 let value1 = sim_random_range(100..1000);
474 let value2 = sim_random_range(0.0..10.0);
475
476 set_sim_seed(123);
477 assert_eq!(value1, sim_random_range(100..1000));
478 assert_f64_eq(value2, sim_random_range(0.0..10.0));
479 }
480
481 #[test]
482 fn test_reset_clears_state() {
483 // Set seed and advance RNG
484 set_sim_seed(42);
485 let _advance1: f64 = sim_random();
486 let _advance2: f64 = sim_random();
487 let after_advance: f64 = sim_random();
488
489 // Reset and set same seed - should get first value, not third
490 reset_sim_rng();
491 set_sim_seed(42);
492 let first_value: f64 = sim_random();
493
494 // Should be different because reset cleared the advanced state
495 assert_f64_ne(after_advance, first_value);
496 }
497
498 #[test]
499 fn test_sequence_persistence_within_thread() {
500 set_sim_seed(42);
501 let value1: f64 = sim_random();
502 let value2: f64 = sim_random();
503 let value3: f64 = sim_random();
504
505 // Values should form a deterministic sequence
506 set_sim_seed(42);
507 assert_f64_eq(value1, sim_random::<f64>());
508 assert_f64_eq(value2, sim_random::<f64>());
509 assert_f64_eq(value3, sim_random::<f64>());
510 }
511
512 #[test]
513 fn test_multiple_resets_and_seeds() {
514 // Test multiple reset/seed cycles
515 for seed in [1, 42, 12345] {
516 reset_sim_rng();
517 set_sim_seed(seed);
518 let first: f64 = sim_random();
519
520 reset_sim_rng();
521 set_sim_seed(seed);
522 assert_f64_eq(first, sim_random::<f64>());
523 }
524 }
525
526 #[test]
527 fn test_current_sim_seed() {
528 // Test getting current seed after setting
529 set_sim_seed(12345);
530 assert_eq!(current_sim_seed(), 12345);
531
532 set_sim_seed(98765);
533 assert_eq!(current_sim_seed(), 98765);
534
535 // Test that reset clears the seed
536 reset_sim_rng();
537 assert_eq!(current_sim_seed(), 0);
538 }
539
540 #[test]
541 fn test_call_counting() {
542 reset_sim_rng();
543 set_sim_seed(42);
544 assert_eq!(rng_call_count(), 0);
545
546 let _: f64 = sim_random();
547 assert_eq!(rng_call_count(), 1);
548
549 let _: u32 = sim_random();
550 assert_eq!(rng_call_count(), 2);
551
552 let _ = sim_random_range(0..100);
553 assert_eq!(rng_call_count(), 3);
554
555 let _ = sim_random_f64();
556 assert_eq!(rng_call_count(), 4);
557
558 // sim_random_range_or_default with valid range delegates to sim_random_range
559 let _ = sim_random_range_or_default(0..100);
560 assert_eq!(rng_call_count(), 5);
561
562 // sim_random_range_or_default with empty range does NOT consume RNG
563 let _ = sim_random_range_or_default(100..100);
564 assert_eq!(rng_call_count(), 5);
565 }
566
567 #[test]
568 fn test_breakpoint_reseed() {
569 reset_sim_rng();
570 set_sim_seed(100);
571
572 // Record first 5 values with seed 100
573 let mut old_values = Vec::new();
574 for _ in 0..5 {
575 old_values.push(sim_random::<f64>());
576 }
577
578 // Record first value with seed 200 from scratch
579 reset_sim_rng();
580 set_sim_seed(200);
581 let new_seed_first: f64 = sim_random();
582
583 // Replay: seed 100, breakpoint at count=5 to reseed to 200
584 reset_sim_rng();
585 set_sim_seed(100);
586 set_rng_breakpoints(vec![(5, 200)]);
587
588 // First 5 calls should match old seed
589 for (i, expected) in old_values.iter().enumerate() {
590 let actual: f64 = sim_random();
591 assert_eq!(
592 expected.to_bits(),
593 actual.to_bits(),
594 "Mismatch at call {}",
595 i + 1
596 );
597 }
598
599 // Call 6 triggers breakpoint (count 6 > 5), reseeds to 200
600 let after_breakpoint: f64 = sim_random();
601 assert_f64_eq(after_breakpoint, new_seed_first);
602 assert_eq!(rng_call_count(), 1);
603 assert_eq!(current_sim_seed(), 200);
604 }
605
606 #[test]
607 fn test_chained_breakpoints() {
608 reset_sim_rng();
609 set_sim_seed(10);
610 set_rng_breakpoints(vec![(3, 20), (2, 30)]);
611
612 // 3 calls with seed 10
613 let _: f64 = sim_random(); // count=1
614 let _: f64 = sim_random(); // count=2
615 let _: f64 = sim_random(); // count=3
616 assert_eq!(current_sim_seed(), 10);
617
618 // Call 4: count becomes 4 > 3, breakpoint fires: reseed to 20, count=1
619 let _: f64 = sim_random();
620 assert_eq!(current_sim_seed(), 20);
621 assert_eq!(rng_call_count(), 1);
622
623 // 1 more call with seed 20
624 let _: f64 = sim_random(); // count=2
625
626 // Call 3 of seed 20: count becomes 3 > 2, breakpoint fires: reseed to 30, count=1
627 let _: f64 = sim_random();
628 assert_eq!(current_sim_seed(), 30);
629 assert_eq!(rng_call_count(), 1);
630 }
631
632 #[test]
633 fn test_replay_determinism() {
634 // Run 1: record a "recipe" — seed 42, fork at call 3 to seed 99
635 reset_sim_rng();
636 set_sim_seed(42);
637 let _: f64 = sim_random();
638 let _: f64 = sim_random();
639 let _: f64 = sim_random();
640 let fork_count = rng_call_count();
641 set_sim_seed(99);
642 reset_rng_call_count();
643 let post_fork_1: f64 = sim_random();
644 let post_fork_2: f64 = sim_random();
645
646 // Run 2: replay using breakpoints
647 reset_sim_rng();
648 set_sim_seed(42);
649 set_rng_breakpoints(vec![(fork_count, 99)]);
650 let _: f64 = sim_random();
651 let _: f64 = sim_random();
652 let _: f64 = sim_random();
653 // Breakpoint triggers on next call (count 4 > 3)
654 let replay_1: f64 = sim_random();
655 let replay_2: f64 = sim_random();
656
657 assert_f64_eq(post_fork_1, replay_1);
658 assert_f64_eq(post_fork_2, replay_2);
659 }
660
661 #[test]
662 fn test_config_rng_does_not_perturb_sim_rng() {
663 // Control: SIM_RNG sequence with no CONFIG_RNG draws interleaved.
664 reset_sim_rng();
665 set_sim_seed(42);
666 let control: Vec<f64> = (0..5).map(|_| sim_random::<f64>()).collect();
667 let control_count = rng_call_count();
668
669 // Experiment: interleave CONFIG_RNG draws between every SIM_RNG draw.
670 reset_sim_rng();
671 set_sim_seed(42);
672 set_config_seed(42);
673 let mut experiment = Vec::new();
674 for _ in 0..5 {
675 let _ = config_random_bool(0.5);
676 let _ = config_random_f64();
677 experiment.push(sim_random::<f64>());
678 }
679
680 // CONFIG_RNG must not perturb the SIM_RNG sequence or its call count.
681 for (c, e) in control.iter().zip(experiment.iter()) {
682 assert_f64_eq(*c, *e);
683 }
684 assert_eq!(rng_call_count(), control_count);
685 }
686
687 #[test]
688 fn test_config_rng_determinism_and_independence_from_seed() {
689 // Same config seed -> same CONFIG_RNG sequence.
690 set_config_seed(7);
691 let a: Vec<f64> = (0..4).map(|_| config_random_f64()).collect();
692 set_config_seed(7);
693 let b: Vec<f64> = (0..4).map(|_| config_random_f64()).collect();
694 for (x, y) in a.iter().zip(b.iter()) {
695 assert_f64_eq(*x, *y);
696 }
697
698 // Salting decorrelates CONFIG_RNG from a same-numbered SIM_RNG seed.
699 set_sim_seed(7);
700 let sim_first: f64 = sim_random();
701 set_config_seed(7);
702 let config_first = config_random_f64();
703 assert_f64_ne(sim_first, config_first);
704 }
705
706 #[test]
707 fn test_reset_clears_everything_including_breakpoints() {
708 set_sim_seed(42);
709 let _: f64 = sim_random();
710 let _: f64 = sim_random();
711 set_rng_breakpoints(vec![(10, 99)]);
712
713 assert_eq!(rng_call_count(), 2);
714
715 reset_sim_rng();
716
717 assert_eq!(rng_call_count(), 0);
718 assert_eq!(current_sim_seed(), 0);
719
720 // Verify breakpoints were cleared
721 set_sim_seed(42);
722 let _: f64 = sim_random();
723 assert_eq!(rng_call_count(), 1);
724 assert_eq!(current_sim_seed(), 42); // no breakpoint triggered
725 }
726
727 #[test]
728 fn select_rng_does_not_perturb_sim_rng_and_replays() {
729 // Control: SIM_RNG sequence with no SELECT_RNG draws interleaved.
730 reset_sim_rng();
731 set_sim_seed(42);
732 let control: Vec<f64> = (0..5).map(|_| sim_random::<f64>()).collect();
733 let control_count = rng_call_count();
734
735 // Experiment: interleave select-offset draws between SIM_RNG draws.
736 reset_sim_rng();
737 set_sim_seed(42);
738 set_select_seed(42);
739 let mut offsets_a = Vec::new();
740 let mut experiment = Vec::new();
741 for _ in 0..5 {
742 offsets_a.push(select_offset_from_stream(8));
743 experiment.push(sim_random::<f64>());
744 }
745 for (c, e) in control.iter().zip(experiment.iter()) {
746 assert_f64_eq(*c, *e);
747 }
748 assert_eq!(
749 rng_call_count(),
750 control_count,
751 "select offsets must not touch the SIM_RNG call count"
752 );
753
754 // Same select seed replays the same offset stream.
755 set_select_seed(42);
756 let offsets_b: Vec<u32> = (0..5).map(|_| select_offset_from_stream(8)).collect();
757 assert_eq!(offsets_a, offsets_b);
758 assert!(
759 offsets_a.iter().any(|&o| o != offsets_a[0]),
760 "offset stream should vary"
761 );
762 reset_select_rng();
763 }
764
765 #[test]
766 fn swarm_op_disabled_enables_full_alphabet() {
767 // No swarm: every operation is enabled (zero behavior change).
768 set_swarm_op_seed(None);
769 for op in 0..32u8 {
770 assert!(
771 swarm_op_enabled(op),
772 "op {op} must be enabled when swarm is off"
773 );
774 }
775 }
776
777 #[test]
778 fn swarm_op_mask_is_idempotent_and_order_independent() {
779 const N: u8 = 16;
780 set_swarm_op_seed(Some(123));
781 let forward: Vec<bool> = (0..N).map(swarm_op_enabled).collect();
782
783 // Re-query in reverse, with repeats: a pure (seed, op) function must
784 // yield the identical mask regardless of call order or count.
785 set_swarm_op_seed(Some(123));
786 let mut reverse = vec![false; usize::from(N)];
787 for op in (0..N).rev() {
788 let _ = swarm_op_enabled(op);
789 reverse[usize::from(op)] = swarm_op_enabled(op);
790 }
791 assert_eq!(
792 forward, reverse,
793 "mask must be idempotent and order-independent"
794 );
795 set_swarm_op_seed(None);
796 }
797
798 #[test]
799 fn swarm_op_varies_across_seeds_and_reaches_extremes() {
800 const N: u8 = 10;
801 let mut min_enabled = usize::MAX;
802 let mut max_enabled = 0usize;
803 for seed in 0..4000u64 {
804 set_swarm_op_seed(Some(seed));
805 let count = (0..N).filter(|&op| swarm_op_enabled(op)).count();
806 min_enabled = min_enabled.min(count);
807 max_enabled = max_enabled.max(count);
808 }
809 // Deterministic across runs (pure hash), but spread across the alphabet:
810 // some seeds yield a near-empty subset, others the full alphabet.
811 assert!(
812 min_enabled <= 1,
813 "expected a near-empty subset; min was {min_enabled}"
814 );
815 assert_eq!(
816 max_enabled,
817 usize::from(N),
818 "expected a full subset; max was {max_enabled}"
819 );
820 set_swarm_op_seed(None);
821 }
822
823 #[test]
824 fn swarm_op_query_does_not_perturb_sim_rng() {
825 reset_sim_rng();
826 set_sim_seed(99);
827 let _: f64 = sim_random();
828 let before = rng_call_count();
829
830 set_swarm_op_seed(Some(5));
831 for op in 0..50u8 {
832 let _ = swarm_op_enabled(op);
833 }
834 assert_eq!(
835 rng_call_count(),
836 before,
837 "swarm_op_enabled must not touch the SIM_RNG call count"
838 );
839 set_swarm_op_seed(None);
840 }
841}