Skip to main content

confium_test_harness/
env.rs

1//! Deterministic environment for reproducible NIST evaluation runs.
2//!
3//! A [`DeterministicEnv`] bundles the four knobs a vector needs to be
4//! replayable bit-for-bit across machines and runs:
5//!
6//! - a seeded [`DeterministicRng`] (a Splittable-style PRNG built on
7//!   `rand_chacha`'s design without pulling in `rand` — we seed a simple
8//!   ChaCha8-like counter stream here, but the concrete implementation is
9//!   a 128-bit splitmix64 that is fully self-contained)
10//! - a [`DeterministicClock`] that advances only when the harness tells
11//!   it to (no wall-clock dependence, no flaky timeouts)
12//! - a [`MemoryCounter`] that tallies bytes the harness attributes to a
13//!   party so the bench can report peak allocation per scheme
14//!
15//! The harness never reads the OS clock or `getrandom` directly — every
16//! source of nondeterminism is funneled through this module so the same
17//! vector + seed yields the same transcript every time.
18
19use std::cell::Cell;
20use std::sync::Mutex;
21
22/// Self-contained deterministic PRNG.
23///
24/// A 64-bit splitmix64 generator seeded from the vector's `seed` field.
25/// Same seed, same call sequence, same bytes — no platform entropy. The
26/// stream is reproducible across machines, which is the whole point for
27/// NIST vectors.
28///
29/// Not cryptographically secure — this exists to make protocol
30/// transcripts reproducible, not to be the production RNG. Schemes under
31/// test plug this in via their nonce/ephemeral-value hooks.
32#[derive(Debug, Clone)]
33pub struct DeterministicRng {
34    state: u64,
35}
36
37impl DeterministicRng {
38    /// Seed a fresh generator. A zero seed is allowed and produces a
39    /// well-defined stream (the first output is non-zero because
40    /// splitmix64 mixes the state before returning).
41    pub fn from_seed(seed: u64) -> Self {
42        DeterministicRng { state: seed }
43    }
44
45    /// Next raw 64-bit output.
46    pub fn next_u64(&mut self) -> u64 {
47        // splitmix64 — identical to the algorithm in the reference
48        // test-vector literature, so cross-language reimplementations
49        // can match byte-for-byte.
50        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
51        let mut z = self.state;
52        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
53        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
54        z ^ (z >> 31)
55    }
56
57    /// Fill `out` with deterministic bytes derived from the stream.
58    pub fn fill(&mut self, out: &mut [u8]) {
59        let mut i = 0;
60        while i + 8 <= out.len() {
61            let w = self.next_u64().to_le_bytes();
62            out[i..i + 8].copy_from_slice(&w);
63            i += 8;
64        }
65        if i < out.len() {
66            let w = self.next_u64().to_le_bytes();
67            let remaining = out.len() - i;
68            out[i..].copy_from_slice(&w[..remaining]);
69        }
70    }
71}
72
73/// Stepping clock that ignores wall time.
74///
75/// The harness calls [`DeterministicClock::advance`] between rounds;
76/// `now` returns the accumulated nanoseconds. Timeouts in protocol
77/// plugins should consult this (via the env handle) rather than
78/// `SystemTime`, so a slow CI box doesn't trip a timeout the vector
79/// never intended to fire.
80#[derive(Debug, Default)]
81pub struct DeterministicClock {
82    nanos: Cell<u128>,
83}
84
85impl DeterministicClock {
86    pub fn new() -> Self {
87        DeterministicClock {
88            nanos: Cell::new(0),
89        }
90    }
91
92    /// Current simulated time in nanoseconds since env start.
93    pub fn now_nanos(&self) -> u128 {
94        self.nanos.get()
95    }
96
97    /// Advance the clock by `nanos`. Monotonic; never goes backwards.
98    pub fn advance(&self, nanos: u64) {
99        self.nanos
100            .set(self.nanos.get().saturating_add(nanos as u128));
101    }
102}
103
104/// Per-party allocation tally.
105///
106/// The harness calls [`MemoryCounter::track`] when a plugin reports it
107/// has allocated on behalf of a party; the counter keeps a running peak.
108/// This is cooperative accounting — the framework can't intercept every
109/// `malloc` — but it gives the bench a consistent, comparable number
110/// across schemes that all play by the same rule.
111#[derive(Debug, Default)]
112pub struct MemoryCounter {
113    inner: Mutex<MemoryState>,
114}
115
116#[derive(Debug, Default, Clone, Copy)]
117struct MemoryState {
118    current: u64,
119    peak: u64,
120}
121
122impl MemoryCounter {
123    pub fn new() -> Self {
124        MemoryCounter::default()
125    }
126
127    /// Account `bytes` of live allocation. Updates the peak if the new
128    /// current exceeds it.
129    pub fn track(&self, bytes: u64) {
130        let mut state = self.inner.lock().expect("memory counter poisoned");
131        state.current = state.current.saturating_add(bytes);
132        if state.current > state.peak {
133            state.peak = state.current;
134        }
135    }
136
137    /// Release previously tracked bytes. Never underflows past zero.
138    pub fn release(&self, bytes: u64) {
139        let mut state = self.inner.lock().expect("memory counter poisoned");
140        state.current = state.current.saturating_sub(bytes);
141    }
142
143    /// Highest live-allocation watermark seen since construction.
144    pub fn peak_bytes(&self) -> u64 {
145        self.inner.lock().expect("memory counter poisoned").peak
146    }
147
148    /// Currently live tracked bytes.
149    pub fn current_bytes(&self) -> u64 {
150        self.inner.lock().expect("memory counter poisoned").current
151    }
152}
153
154/// The four-knob deterministic bundle handed to a vector run.
155///
156/// Construct with [`DeterministicEnv::from_seed`]; pass clones into each
157/// party. The RNG is the only piece that must not be shared between
158/// parties — give each party its own fork via [`DeterministicEnv::fork`]
159/// so their streams diverge deterministically by party index.
160#[derive(Debug)]
161pub struct DeterministicEnv {
162    seed: u64,
163    clock: DeterministicClock,
164    memory: MemoryCounter,
165}
166
167impl DeterministicEnv {
168    /// Build an env rooted at `seed`. All forked RNGs derive from this.
169    pub fn from_seed(seed: u64) -> Self {
170        DeterministicEnv {
171            seed,
172            clock: DeterministicClock::new(),
173            memory: MemoryCounter::new(),
174        }
175    }
176
177    pub fn seed(&self) -> u64 {
178        self.seed
179    }
180
181    /// A fresh RNG for party `idx`. Mixing the party index into the seed
182    /// means two parties never share a stream but the assignment is
183    /// still deterministic for a given (env seed, roster).
184    pub fn rng_for(&self, party_idx: usize) -> DeterministicRng {
185        let mixed = self
186            .seed
187            .wrapping_mul(0x9E37_79B9_7F4A_7C15)
188            .wrapping_add(party_idx as u64);
189        DeterministicRng::from_seed(mixed)
190    }
191
192    /// Fork this env for a child party: shares the clock and memory
193    /// counter (those are session-wide) but yields a party-specific RNG.
194    pub fn fork(&self, party_idx: usize) -> ForkedEnv<'_> {
195        ForkedEnv {
196            rng: self.rng_for(party_idx),
197            clock: &self.clock,
198            memory: &self.memory,
199        }
200    }
201
202    pub fn clock(&self) -> &DeterministicClock {
203        &self.clock
204    }
205
206    pub fn memory(&self) -> &MemoryCounter {
207        &self.memory
208    }
209}
210
211/// Per-party view of a [`DeterministicEnv`]: its own RNG, borrowed clock
212/// and memory counter shared with siblings.
213#[derive(Debug, Clone)]
214pub struct ForkedEnv<'a> {
215    pub rng: DeterministicRng,
216    pub clock: &'a DeterministicClock,
217    pub memory: &'a MemoryCounter,
218}
219
220impl<'a> ForkedEnv<'a> {
221    /// Borrow the per-party RNG mutably for nonce / ephemeral generation.
222    pub fn rng_mut(&mut self) -> &mut DeterministicRng {
223        &mut self.rng
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn seeded_rng_is_reproducible() {
233        let mut a = DeterministicRng::from_seed(0xDEAD_BEEF_CAFE_BABE);
234        let mut b = DeterministicRng::from_seed(0xDEAD_BEEF_CAFE_BABE);
235        for _ in 0..32 {
236            assert_eq!(a.next_u64(), b.next_u64());
237        }
238    }
239
240    #[test]
241    fn different_seeds_diverge() {
242        let mut a = DeterministicRng::from_seed(1);
243        let mut b = DeterministicRng::from_seed(2);
244        // Overwhelmingly likely to differ at least once in 16 draws.
245        let differs = (0..16).any(|_| a.next_u64() != b.next_u64());
246        assert!(differs, "two different seeds produced identical streams");
247    }
248
249    #[test]
250    fn zero_seed_still_produces_output() {
251        let mut rng = DeterministicRng::from_seed(0);
252        // splitmix64 mixes before returning, so zero seed is not a fixed
253        // point.
254        let first = rng.next_u64();
255        let second = rng.next_u64();
256        assert_ne!(first, second);
257    }
258
259    #[test]
260    fn fill_is_byte_reproducible() {
261        let mut a = [0u8; 17];
262        let mut b = [0u8; 17];
263        DeterministicRng::from_seed(42).fill(&mut a);
264        DeterministicRng::from_seed(42).fill(&mut b);
265        assert_eq!(a, b);
266        assert!(a.iter().any(|&x| x != 0));
267    }
268
269    #[test]
270    fn clock_starts_at_zero_and_advances() {
271        let clock = DeterministicClock::new();
272        assert_eq!(clock.now_nanos(), 0);
273        clock.advance(1_000_000_000);
274        assert_eq!(clock.now_nanos(), 1_000_000_000);
275        clock.advance(500_000_000);
276        assert_eq!(clock.now_nanos(), 1_500_000_000);
277    }
278
279    #[test]
280    fn clock_does_not_overflow() {
281        let clock = DeterministicClock::new();
282        clock.advance(u64::MAX);
283        clock.advance(u64::MAX);
284        assert!(clock.now_nanos() > u64::MAX as u128);
285    }
286
287    #[test]
288    fn memory_counter_tracks_peak() {
289        let mem = MemoryCounter::new();
290        mem.track(100);
291        mem.track(200);
292        assert_eq!(mem.current_bytes(), 300);
293        assert_eq!(mem.peak_bytes(), 300);
294        mem.release(150);
295        assert_eq!(mem.current_bytes(), 150);
296        assert_eq!(mem.peak_bytes(), 300, "peak is sticky");
297        mem.track(400);
298        assert_eq!(mem.peak_bytes(), 550);
299    }
300
301    #[test]
302    fn memory_counter_release_underflows_to_zero() {
303        let mem = MemoryCounter::new();
304        mem.release(1_000_000);
305        assert_eq!(mem.current_bytes(), 0);
306        assert_eq!(mem.peak_bytes(), 0);
307    }
308
309    #[test]
310    fn env_forks_diverge_by_party_index() {
311        let env = DeterministicEnv::from_seed(99);
312        let mut fork_a = env.fork(0);
313        let mut fork_b = env.fork(1);
314        let x = fork_a.rng_mut().next_u64();
315        let y = fork_b.rng_mut().next_u64();
316        assert_ne!(
317            x, y,
318            "two parties must not share an RNG stream in the same session"
319        );
320    }
321
322    #[test]
323    fn env_fork_shares_clock_and_memory() {
324        let env = DeterministicEnv::from_seed(7);
325        let fork_a = env.fork(0);
326        let fork_b = env.fork(1);
327        fork_a.memory.track(128);
328        assert_eq!(fork_b.memory.peak_bytes(), 128);
329        fork_a.clock.advance(5);
330        assert_eq!(fork_b.clock.now_nanos(), 5);
331    }
332}