//! The testkit's single source of pseudo-randomness. //! //! Appendix D §"Randomness" forbids platform entropy from reaching canonical //! state; the testkit holds itself to the stronger rule that **no platform //! entropy enters the harness at all**, so every generated case reproduces //! exactly from its seed. All draws route through [`Rng`], a thin ergonomic //! wrapper over Agent A's vendored SplitMix64 //! ([`epiphany_determinism::fuzz::SplitMix64`]) — the same generator the //! determinism and bundle fuzzers use, so seeds are comparable across crates. use epiphany_determinism::fuzz::SplitMix64; /// A seeded, fully deterministic generator with the small set of draws the /// testkit's generators and harnesses need. Reproducible across platforms. pub struct Rng { inner: SplitMix64, } impl Rng { /// Seeds the generator. Identical seeds produce identical streams. pub fn new(seed: u64) -> Self { Rng { inner: SplitMix64::new(seed), } } /// A raw 64-bit draw. #[inline] pub fn next_u64(&mut self) -> u64 { self.inner.next_u64() } /// A draw in `0..n`. Panics if `n == 0`. #[inline] pub fn below(&mut self, n: u64) -> u64 { assert!(n > 0, "Rng::below requires a positive bound"); // Unbiased: reject the top partial bucket so every residue is equally // likely (plain `% n` over-weights the low residues). `n.wrapping_neg() // % n == 2^64 mod n` is the size of that partial bucket. if n.is_power_of_two() { return self.next_u64() & (n - 1); } let reject_below = n.wrapping_neg() % n; loop { let x = self.next_u64(); if x >= reject_below { return x % n; } } } /// An inclusive draw in `lo..=hi`. Panics if `lo > hi`. Handles the full /// `0..=u64::MAX` span without the `hi - lo + 1` overflow. #[inline] pub fn range(&mut self, lo: u64, hi: u64) -> u64 { assert!(lo <= hi, "Rng::range requires lo <= hi"); let span = hi - lo; if span == u64::MAX { return self.next_u64(); } lo + self.below(span + 1) } /// An inclusive `usize` draw in `lo..=hi`. #[inline] pub fn range_usize(&mut self, lo: usize, hi: usize) -> usize { self.range(lo as u64, hi as u64) as usize } /// A fair coin. #[inline] pub fn boolean(&mut self) -> bool { self.next_u64() & 1 == 0 } /// A reference to a uniformly chosen element. Panics on an empty slice. #[inline] pub fn choose<'a, T>(&mut self, items: &'a [T]) -> &'a T { assert!(!items.is_empty(), "Rng::choose on an empty slice"); &items[self.below(items.len() as u64) as usize] } /// `len` pseudo-random bytes. pub fn bytes(&mut self, len: usize) -> Vec { let mut out = Vec::with_capacity(len); while out.len() < len { let word = self.next_u64().to_le_bytes(); let take = (len - out.len()).min(8); out.extend_from_slice(&word[..take]); } out } /// A vector of `lo..=hi` pseudo-random bytes (length drawn first). Avoids /// the nested-`&mut self` borrow of `bytes(range_usize(..))`. pub fn byte_vec(&mut self, lo: usize, hi: usize) -> Vec { let len = self.range_usize(lo, hi); self.bytes(len) } /// A fixed 32-byte draw (one content-hash worth). pub fn array32(&mut self) -> [u8; 32] { let mut out = [0u8; 32]; for chunk in out.chunks_mut(8) { chunk.copy_from_slice(&self.next_u64().to_le_bytes()); } out } /// A fixed 16-byte draw (one document-id / file-uuid worth). pub fn array16(&mut self) -> [u8; 16] { let mut out = [0u8; 16]; out[..8].copy_from_slice(&self.next_u64().to_le_bytes()); out[8..].copy_from_slice(&self.next_u64().to_le_bytes()); out } /// Fisher–Yates in-place shuffle. Deterministic for a given seed; this is /// how the convergence harness produces its *N random delivery orders*. pub fn shuffle(&mut self, items: &mut [T]) { let n = items.len(); if n < 2 { return; } for i in (1..n).rev() { // Unbiased draw in 0..=i (routes through `below`'s rejection sampling // rather than a raw modulo). let j = self.below(i as u64 + 1) as usize; items.swap(i, j); } } /// A fresh permutation of `0..n`. pub fn permutation(&mut self, n: usize) -> Vec { let mut v: Vec = (0..n).collect(); self.shuffle(&mut v); v } } #[cfg(test)] mod tests { use super::*; #[test] fn is_deterministic() { let mut a = Rng::new(1234); let mut b = Rng::new(1234); for _ in 0..1000 { assert_eq!(a.next_u64(), b.next_u64()); } } #[test] fn shuffle_is_a_permutation() { let mut rng = Rng::new(7); let mut v: Vec = (0..50).collect(); rng.shuffle(&mut v); let mut sorted = v.clone(); sorted.sort_unstable(); assert_eq!(sorted, (0..50).collect::>()); // And it actually moved something (astronomically unlikely to be identity). assert_ne!(v, (0..50).collect::>()); } #[test] fn bounded_draws_stay_in_range() { let mut rng = Rng::new(99); for _ in 0..10_000 { let lo = rng.below(10); let hi = lo + rng.below(10); let x = rng.range(lo, hi); assert!(lo <= x && x <= hi); } } #[test] fn full_range_does_not_overflow() { // `range(0, u64::MAX)` must not compute `hi - lo + 1` (which overflows, // and panics under the workspace's release overflow-checks). let mut rng = Rng::new(123); for _ in 0..1000 { let _ = rng.range(0, u64::MAX); } // And a near-full span. let _ = rng.range(5, u64::MAX); } #[test] fn below_is_approximately_uniform() { // A coarse chi-square-free check: each of n buckets gets a fair share. let mut rng = Rng::new(0xBEEF); let n = 7u64; // non-power-of-two exercises the rejection path let trials = 70_000u64; let mut counts = [0u64; 7]; for _ in 0..trials { counts[rng.below(n) as usize] += 1; } let expected = trials / n; for c in counts { // Within 10% of the expected share — loose, just catches gross bias. assert!( c > expected * 9 / 10 && c < expected * 11 / 10, "bucket count {c} far from expected {expected}" ); } } }