Skip to main content

proxima_primitives/pipe/
when.rs

1use serde::{Deserialize, Serialize};
2
3// ── seeded stochastic decision — the reusable keystone ───────────────────────
4//
5// `When` is a config-expressible, seeded, DETERMINISTIC coin flip. It is the
6// shared primitive behind chaos injection, fuzz-gating, sampling, and canary
7// routing — none of which are baked in here. The contract is narrow on purpose:
8// `fires(call_index)` is a pure function of `(seed, prob, call_index)`. No
9// global RNG, no wall-clock, no `Instant` — the same `(seed, prob)` always
10// produces the same fire sequence, so a config replays bit-for-bit.
11
12/// A seeded, deterministic stochastic gate.
13///
14/// `fires(call_index)` returns `true` with probability `prob`, derived purely
15/// from `(seed, call_index)` — reproducible across runs, machines, and threads.
16/// Wire it as a `Filter` predicate to drop/pass a fraction of calls, or reuse
17/// it directly for sampling/canary/chaos decisions.
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19pub struct When {
20    /// Fire probability in `[0, 1]`. `0.0` never fires; `1.0` always fires.
21    pub prob: f64,
22    /// Seed pinning the fire sequence. Same `(seed, prob)` => same sequence.
23    pub seed: u64,
24}
25
26impl When {
27    /// Start a builder from the fire probability (clamped into `[0, 1]`).
28    #[must_use]
29    pub fn prob(probability: f64) -> Self {
30        Self {
31            prob: probability.clamp(0.0, 1.0),
32            seed: 0,
33        }
34    }
35
36    /// Pin the seed for the fire sequence.
37    #[must_use]
38    pub fn seed(mut self, seed: u64) -> Self {
39        self.seed = seed;
40        self
41    }
42
43    /// Deterministically decide whether call `call_index` fires. Pure in
44    /// `(seed, prob, call_index)`: the rng is seeded per call so the result
45    /// never depends on prior calls, ordering, or any global state.
46    #[must_use]
47    pub fn fires(&self, call_index: u64) -> bool {
48        if self.prob <= 0.0 {
49            return false;
50        }
51        if self.prob >= 1.0 {
52            return true;
53        }
54        let mut rng = fastrand::Rng::with_seed(self.seed.wrapping_add(call_index));
55        rng.f64() < self.prob
56    }
57}
58
59#[cfg(test)]
60#[allow(clippy::unwrap_used, clippy::expect_used)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn fire_sequence_is_deterministic_across_runs() {
66        let gate = When::prob(0.5).seed(0xABCD_1234);
67        let first: Vec<bool> = (0..512).map(|index| gate.fires(index)).collect();
68        let second: Vec<bool> = (0..512).map(|index| gate.fires(index)).collect();
69        assert_eq!(
70            first, second,
71            "same (seed, prob) yields an identical fire sequence"
72        );
73    }
74
75    #[test]
76    fn prob_zero_never_fires_and_prob_one_always_fires() {
77        let never = When::prob(0.0).seed(7);
78        let always = When::prob(1.0).seed(7);
79        for index in 0..1_000 {
80            assert!(!never.fires(index), "prob 0.0 must never fire");
81            assert!(always.fires(index), "prob 1.0 must always fire");
82        }
83    }
84
85    #[test]
86    fn prob_is_clamped_into_unit_interval() {
87        assert_eq!(
88            When::prob(-3.0).prob,
89            0.0,
90            "negative probability clamps to 0"
91        );
92        assert_eq!(
93            When::prob(42.0).prob,
94            1.0,
95            "above-one probability clamps to 1"
96        );
97    }
98
99    #[test]
100    fn distinct_seeds_diverge() {
101        let left = When::prob(0.5).seed(1);
102        let right = When::prob(0.5).seed(2);
103        let diverged = (0..512).any(|index| left.fires(index) != right.fires(index));
104        assert!(diverged, "different seeds must produce different sequences");
105    }
106
107    #[test]
108    fn empirical_fire_rate_tracks_probability() {
109        let gate = When::prob(0.25).seed(99);
110        let fired = (0..10_000_u64).filter(|&index| gate.fires(index)).count();
111        let rate = fired as f64 / 10_000.0;
112        assert!(
113            (rate - 0.25).abs() < 0.05,
114            "observed rate {rate} should track prob 0.25"
115        );
116    }
117}