Skip to main content

sc_neurocore_engine/
fault.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Hardware fault injection for robustness testing
8
9//! Hardware fault injection for robustness testing.
10
11use rand::{RngExt, SeedableRng};
12use rand_distr::{Distribution, Normal};
13use rand_xoshiro::Xoshiro256PlusPlus;
14
15/// Flip random bits in packed u64 words with given probability per bit.
16pub fn inject_bitflips(data: &mut [u64], rate: f64, seed: u64) {
17    if rate <= 0.0 {
18        return;
19    }
20    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
21    for word in data.iter_mut() {
22        let mut flip_mask = 0u64;
23        for bit in 0..64 {
24            if rng.random::<f64>() < rate {
25                flip_mask |= 1u64 << bit;
26            }
27        }
28        *word ^= flip_mask;
29    }
30}
31
32/// Force bits to a fixed value with given probability per bit.
33pub fn inject_stuck_at(data: &mut [u64], rate: f64, value: bool, seed: u64) {
34    if rate <= 0.0 {
35        return;
36    }
37    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
38    for word in data.iter_mut() {
39        for bit in 0..64 {
40            if rng.random::<f64>() < rate {
41                if value {
42                    *word |= 1u64 << bit;
43                } else {
44                    *word &= !(1u64 << bit);
45                }
46            }
47        }
48    }
49}
50
51// ── Byte-level inject ops (parity with Python `FaultInjector.inject`) ──
52//
53// These accept and return `[u8]` where each element is 0 or 1, mirroring
54// numpy `bool` arrays serialised as uint8. The RNG (Xoshiro256PlusPlus)
55// differs from numpy's PCG64, so bitwise parity vs Python is impossible
56// — statistical parity within 3-sigma of expected fault count is the
57// honest reference (see `tests/test_fault_injection_rust_parity.py`).
58
59/// BIT_FLIP: each input bit flips with probability `ber`.
60/// Returns number of bits flipped.
61pub fn inject_bitflip_u8(bitstream: &mut [u8], ber: f64, seed: u64) -> u64 {
62    if ber <= 0.0 {
63        return 0;
64    }
65    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
66    let mut flipped: u64 = 0;
67    for b in bitstream.iter_mut() {
68        if rng.random::<f64>() < ber {
69            *b ^= 1;
70            flipped += 1;
71        }
72    }
73    flipped
74}
75
76/// STUCK_AT_0: each bit forced to 0 with probability `ber`.
77/// Returns number of 1-bits actually changed (i.e., mask AND original).
78pub fn inject_stuck_at_0_u8(bitstream: &mut [u8], ber: f64, seed: u64) -> u64 {
79    if ber <= 0.0 {
80        return 0;
81    }
82    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
83    let mut affected: u64 = 0;
84    for b in bitstream.iter_mut() {
85        if rng.random::<f64>() < ber {
86            if *b != 0 {
87                affected += 1;
88            }
89            *b = 0;
90        }
91    }
92    affected
93}
94
95/// STUCK_AT_1: each bit forced to 1 with probability `ber`.
96/// Returns number of 0-bits actually changed (i.e., mask AND NOT original).
97pub fn inject_stuck_at_1_u8(bitstream: &mut [u8], ber: f64, seed: u64) -> u64 {
98    if ber <= 0.0 {
99        return 0;
100    }
101    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
102    let mut affected: u64 = 0;
103    for b in bitstream.iter_mut() {
104        if rng.random::<f64>() < ber {
105            if *b == 0 {
106                affected += 1;
107            }
108            *b = 1;
109        }
110    }
111    affected
112}
113
114/// DROPOUT: equivalent to STUCK_AT_0 in this fault model.
115pub fn inject_dropout_u8(bitstream: &mut [u8], ber: f64, seed: u64) -> u64 {
116    inject_stuck_at_0_u8(bitstream, ber, seed)
117}
118
119/// GAUSSIAN_NOISE: add N(0, ber) noise to bitstream cast to f64,
120/// clip to [0,1], then threshold at 0.5. Returns count of flipped bits.
121pub fn inject_gaussian_u8(bitstream: &mut [u8], ber: f64, seed: u64) -> u64 {
122    if ber <= 0.0 {
123        return 0;
124    }
125    let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
126    let normal = Normal::new(0.0_f64, ber).expect("ber > 0");
127    let mut flipped: u64 = 0;
128    for b in bitstream.iter_mut() {
129        let original = *b;
130        let noisy = (original as f64 + normal.sample(&mut rng)).clamp(0.0, 1.0);
131        let new_bit: u8 = if noisy > 0.5 { 1 } else { 0 };
132        if new_bit != original {
133            flipped += 1;
134        }
135        *b = new_bit;
136    }
137    flipped
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn zero_rate_no_change() {
146        let mut data = vec![0xDEAD_BEEF_CAFE_BABEu64; 4];
147        let original = data.clone();
148        inject_bitflips(&mut data, 0.0, 42);
149        assert_eq!(data, original);
150    }
151
152    #[test]
153    fn full_rate_flips_all() {
154        let mut data = vec![0u64; 2];
155        inject_bitflips(&mut data, 1.0, 42);
156        assert_eq!(data, vec![u64::MAX; 2]);
157    }
158
159    #[test]
160    fn stuck_at_zero() {
161        let mut data = vec![u64::MAX; 2];
162        inject_stuck_at(&mut data, 1.0, false, 42);
163        assert_eq!(data, vec![0u64; 2]);
164    }
165
166    #[test]
167    fn stuck_at_one() {
168        let mut data = vec![0u64; 2];
169        inject_stuck_at(&mut data, 1.0, true, 42);
170        assert_eq!(data, vec![u64::MAX; 2]);
171    }
172
173    #[test]
174    fn partial_rate_changes_some() {
175        let mut data = vec![0u64; 8];
176        inject_bitflips(&mut data, 0.5, 99);
177        let total_set: u32 = data.iter().map(|w| w.count_ones()).sum();
178        // ~50% of 512 bits = ~256 ± reasonable margin
179        assert!(total_set > 100 && total_set < 400);
180    }
181
182    // ── Byte-level inject parity tests ──
183
184    #[test]
185    fn bitflip_u8_zero_rate_no_change() {
186        let mut bs = vec![0u8, 1, 0, 1, 1, 0, 1, 0];
187        let original = bs.clone();
188        let n = inject_bitflip_u8(&mut bs, 0.0, 7);
189        assert_eq!(n, 0);
190        assert_eq!(bs, original);
191    }
192
193    #[test]
194    fn bitflip_u8_full_rate_inverts_all() {
195        let mut bs = vec![0u8, 1, 0, 1, 0, 1, 0, 1];
196        let n = inject_bitflip_u8(&mut bs, 1.0, 7);
197        assert_eq!(n as usize, bs.len());
198        assert_eq!(bs, vec![1u8, 0, 1, 0, 1, 0, 1, 0]);
199    }
200
201    #[test]
202    fn bitflip_u8_statistical_count_within_3sigma() {
203        let n = 100_000usize;
204        let ber = 1e-3_f64;
205        let mut bs = vec![0u8; n];
206        let flipped = inject_bitflip_u8(&mut bs, ber, 42);
207        // Binomial(n, ber) → mean = n*ber = 100, sigma = sqrt(n*ber*(1-ber)) ≈ 9.99
208        let mean = n as f64 * ber;
209        let sigma = (n as f64 * ber * (1.0 - ber)).sqrt();
210        let lo = (mean - 4.0 * sigma) as u64;
211        let hi = (mean + 4.0 * sigma) as u64;
212        assert!(
213            flipped >= lo && flipped <= hi,
214            "flipped={flipped} not in [{lo},{hi}]"
215        );
216    }
217
218    #[test]
219    fn stuck_at_0_only_counts_actual_changes() {
220        // All bits already 0 → no change should be counted even if rate = 1.0
221        let mut bs = vec![0u8; 64];
222        let n = inject_stuck_at_0_u8(&mut bs, 1.0, 11);
223        assert_eq!(n, 0);
224        assert!(bs.iter().all(|&b| b == 0));
225    }
226
227    #[test]
228    fn stuck_at_1_only_counts_actual_changes() {
229        let mut bs = vec![1u8; 64];
230        let n = inject_stuck_at_1_u8(&mut bs, 1.0, 11);
231        assert_eq!(n, 0);
232        assert!(bs.iter().all(|&b| b == 1));
233    }
234
235    #[test]
236    fn gaussian_noise_zero_sigma_no_change() {
237        let mut bs = vec![0u8, 1, 0, 1];
238        let original = bs.clone();
239        let n = inject_gaussian_u8(&mut bs, 0.0, 5);
240        assert_eq!(n, 0);
241        assert_eq!(bs, original);
242    }
243
244    #[test]
245    fn dropout_equivalent_to_stuck_at_0() {
246        let mut a = vec![0u8, 1, 1, 0, 1, 0, 1, 1];
247        let mut b = a.clone();
248        let na = inject_dropout_u8(&mut a, 0.5, 17);
249        let nb = inject_stuck_at_0_u8(&mut b, 0.5, 17);
250        assert_eq!(a, b);
251        assert_eq!(na, nb);
252    }
253}