Skip to main content

alice_analytics/
privacy.rs

1//! Local Differential Privacy (LDP) Mechanisms
2//!
3//! Privacy-preserving data collection where noise is added at the source.
4//! Individual data points are deniable, but aggregate statistics emerge.
5
6#[cfg(not(feature = "std"))]
7use crate::math::FloatExt;
8use crate::math::{f64_i64, hash_index, i64_f64, u64_f64};
9use alice_det_math::ln64;
10// the only caller (`RandomizedResponse::new`) needs system entropy
11#[cfg(feature = "std")]
12use alice_det_math::exp64;
13
14// ============================================================================
15// Random Number Generation (ChaCha20-based for determinism)
16// ============================================================================
17
18/// Simple xorshift64 PRNG for fast random numbers
19///
20/// Not cryptographically secure, but fast and sufficient for noise injection.
21#[derive(Clone, Debug)]
22pub struct XorShift64 {
23    state: u64,
24}
25
26impl XorShift64 {
27    /// Create a new PRNG with given seed
28    #[inline]
29    #[must_use]
30    pub const fn new(seed: u64) -> Self {
31        // Ensure non-zero state
32        Self {
33            state: if seed == 0 {
34                0x853c_49e6_748f_ea9b
35            } else {
36                seed
37            },
38        }
39    }
40
41    /// Create from system entropy (`std` only: `SystemTime`)
42    ///
43    /// `no_std` には entropy source が無いので存在しない (2026-09-15 まで固定 seed を返す
44    /// stub があり、差分プライバシーの noise が決定論になっていた) — `no_std` では
45    /// `XorShift64::new(seed)` / `*::with_seed` / `with_probability` / `with_seed_params` に
46    /// caller が entropy を渡す
47    #[cfg(feature = "std")]
48    #[must_use]
49    pub fn from_entropy() -> Self {
50        use std::time::{SystemTime, UNIX_EPOCH};
51        let seed = SystemTime::now()
52            .duration_since(UNIX_EPOCH)
53            // 下位 64 bit で十分 (seed 用途、u128 → u64 の切り捨ては意図)
54            .map_or(0x853c_49e6_748f_ea9b, |d| {
55                #[allow(clippy::cast_possible_truncation)]
56                let low = d.as_nanos() as u64;
57                low
58            });
59        Self::new(seed)
60    }
61
62    /// Generate next u64
63    #[inline]
64    pub const fn next_u64(&mut self) -> u64 {
65        let mut x = self.state;
66        x ^= x << 13;
67        x ^= x >> 7;
68        x ^= x << 17;
69        self.state = x;
70        x
71    }
72
73    /// Generate uniform f64 in [0, 1)
74    #[inline(always)]
75    pub fn next_f64(&mut self) -> f64 {
76        // Multiply by 1/2^53 instead of dividing
77        const RCP_POW2_53: f64 = 1.0 / u64_f64(1u64 << 53);
78        u64_f64(self.next_u64() >> 11) * RCP_POW2_53
79    }
80
81    /// Generate uniform f64 in [low, high)
82    #[inline]
83    pub fn next_f64_range(&mut self, low: f64, high: f64) -> f64 {
84        (high - low).mul_add(self.next_f64(), low)
85    }
86
87    /// Generate a random boolean with given probability of true
88    #[inline]
89    pub fn next_bool(&mut self, p: f64) -> bool {
90        self.next_f64() < p
91    }
92}
93
94#[cfg(feature = "std")]
95impl Default for XorShift64 {
96    fn default() -> Self {
97        Self::from_entropy()
98    }
99}
100
101// ============================================================================
102// Laplace Distribution
103// ============================================================================
104
105/// Generate Laplace-distributed random variable
106///
107/// Laplace(μ, b) has PDF: f(x) = (1/2b) * exp(-|x-μ|/b)
108///
109/// Used for ε-differential privacy with sensitivity Δf:
110/// noise scale b = Δf / ε
111#[derive(Clone, Debug)]
112pub struct LaplaceNoise {
113    /// Scale parameter b
114    scale: f64,
115    /// PRNG
116    rng: XorShift64,
117}
118
119impl LaplaceNoise {
120    /// Create a new Laplace noise generator
121    ///
122    /// # Arguments
123    /// * `sensitivity` - Maximum change in output for one input change (Δf)
124    /// * `epsilon` - Privacy parameter ε (smaller = more privacy)
125    ///
126    /// `std` only (system entropy); `no_std` は [`Self::with_seed`]
127    #[cfg(feature = "std")]
128    #[must_use]
129    pub fn new(sensitivity: f64, epsilon: f64) -> Self {
130        let scale = sensitivity / epsilon;
131        Self {
132            scale,
133            rng: XorShift64::from_entropy(),
134        }
135    }
136
137    /// Create with explicit seed
138    #[must_use]
139    pub fn with_seed(sensitivity: f64, epsilon: f64, seed: u64) -> Self {
140        let scale = sensitivity / epsilon;
141        Self {
142            scale,
143            rng: XorShift64::new(seed),
144        }
145    }
146
147    /// Generate Laplace noise
148    #[inline]
149    pub fn sample(&mut self) -> f64 {
150        // Inverse transform sampling: X = μ - b * sign(U - 0.5) * ln(1 - 2|U - 0.5|)
151        let u = self.rng.next_f64() - 0.5;
152        let sign = if u < 0.0 { -1.0 } else { 1.0 };
153        -sign * self.scale * ln64(2.0f64.mul_add(-u.abs(), 1.0))
154    }
155
156    /// Add noise to a value
157    #[inline]
158    pub fn privatize(&mut self, value: f64) -> f64 {
159        value + self.sample()
160    }
161
162    /// Add noise to an integer value (rounds result)
163    #[inline]
164    pub fn privatize_int(&mut self, value: i64) -> i64 {
165        f64_i64((i64_f64(value) + self.sample()).round())
166    }
167
168    /// Get the scale parameter
169    #[inline]
170    #[must_use]
171    pub const fn scale(&self) -> f64 {
172        self.scale
173    }
174}
175
176// ============================================================================
177// Randomized Response (RAPPOR-style)
178// ============================================================================
179
180/// Randomized Response for binary data
181///
182/// Classic technique: to report a sensitive bit b:
183/// - With probability p, report b truthfully
184/// - With probability 1-p, report a random bit
185///
186/// This provides ε-differential privacy where ε = ln((p + 0.5(1-p)) / (0.5(1-p)))
187#[derive(Clone, Debug)]
188pub struct RandomizedResponse {
189    /// Probability of truthful response
190    p_true: f64,
191    /// PRNG
192    rng: XorShift64,
193}
194
195impl RandomizedResponse {
196    /// Create from privacy parameter epsilon
197    ///
198    /// Higher epsilon = more accuracy, less privacy
199    ///
200    /// `std` only (system entropy); `no_std` は [`Self::with_probability`]
201    ///
202    /// # Panics
203    ///
204    /// `epsilon` が有限でない、または負の場合 検査するのは入力の `epsilon` で、
205    /// `p_true` はそこからの導出値 負の ε は `p_true < 0.5` を作り、
206    /// [`Self::estimate_proportion`] の推定値の符号が反転する
207    /// (実測: `epsilon = -5.0` で `p_true = 6.69e-3`) ⚠️ 2026-10-07 までは
208    /// [`Self::with_probability`] だけが定義域を検査していたので、`new` 経由だと
209    /// 同じ不変条件を破れる状態だった
210    ///
211    /// `epsilon = 0.0` は許す (`p_true = 0.5` = 常に無作為に答える、
212    /// `with_probability(0.5)` と同じ意味のある端)
213    ///
214    /// 上端は切らない: `p = 1/(1 + e^(−ε))` (logistic の数値安定形) で計算するので
215    /// `ε → ∞` の極限 `1.0` をそのまま返す ⚠️ 旧式の `e^ε/(1 + e^ε)` は
216    /// `e^ε` が `ε ≳ 709.79` で `inf` になり `inf/inf = NaN` を返していた (実測)
217    /// NaN は**代数的な書き方の副産物で定義域の問題ではない**ので、ε の上限を
218    /// assert で切るのでなく式を直した (ε = 0 / 0.5 / 1 / 50 / 709 では旧式と
219    /// bit 一致、ε = 3 で 1 ulp 異なる)
220    #[cfg(feature = "std")]
221    #[must_use]
222    pub fn new(epsilon: f64) -> Self {
223        assert!(
224            epsilon.is_finite() && epsilon >= 0.0,
225            "epsilon must be finite and non-negative"
226        );
227        // p = e^ε / (1 + e^ε) = 1 / (1 + e^(−ε)): for ε ≥ 0 the exponential
228        // cannot overflow in this form, so the whole domain is representable
229        let p_true = 1.0 / (1.0 + exp64(-epsilon));
230        Self {
231            p_true,
232            rng: XorShift64::from_entropy(),
233        }
234    }
235
236    /// Create with explicit probability and seed
237    ///
238    /// # Panics
239    ///
240    /// `p_true` が `[0.5, 1.0]` の外 (`NaN` / 無限大を含む、どちらも比較が
241    /// 偽になるので同じ assert で弾かれる) の場合
242    ///
243    /// 定義域の扱いは同 crate の [`ExponentialMovingAverage::new`] に合わせた
244    /// (範囲外は黙って丸めず panic する) 2026-10-07 までは
245    /// `clamp(0.5, 1.0)` だったので、`0.0` を渡すと黙って `0.5` に、`2.0` は
246    /// `1.0` になり、**`NaN` は `clamp` が `NaN` を返すので丸められずに残り**、
247    /// `p_true()` が `NaN` を報告する機構ができていた 丸めは呼び出し側が
248    /// 頼んだのと違う ε の機構を作るので、推定値の意味が変わる
249    ///
250    /// `0.5` は「常に無作為に答える」という意味のある下端なので許す
251    /// (`new(0.0)` がちょうど `0.5` を作る)
252    ///
253    /// [`ExponentialMovingAverage::new`]: crate::streaming_ops::ExponentialMovingAverage::new
254    #[must_use]
255    // `RangeInclusive::contains` is not a `const fn`, so the range cannot be
256    // spelled the way clippy prefers without dropping `const` from a public
257    // constructor.
258    #[allow(clippy::manual_range_contains)]
259    pub const fn with_probability(p_true: f64, seed: u64) -> Self {
260        assert!(
261            p_true >= 0.5 && p_true <= 1.0,
262            "p_true must be in [0.5, 1.0]"
263        );
264        Self {
265            p_true,
266            rng: XorShift64::new(seed),
267        }
268    }
269
270    /// Privatize a boolean value
271    #[inline]
272    pub fn privatize(&mut self, value: bool) -> bool {
273        if self.rng.next_bool(self.p_true) {
274            // Report truthfully
275            value
276        } else {
277            // Report random
278            self.rng.next_bool(0.5)
279        }
280    }
281
282    /// Privatize a bit (0 or 1)
283    #[inline]
284    pub fn privatize_bit(&mut self, bit: u8) -> u8 {
285        u8::from(self.privatize(bit != 0))
286    }
287
288    /// Get the probability of truthful response
289    #[inline]
290    #[must_use]
291    pub const fn p_true(&self) -> f64 {
292        self.p_true
293    }
294
295    /// Estimate true proportion from noisy counts
296    ///
297    /// Given N total responses with K positive responses,
298    /// estimate the true proportion of positive values.
299    #[must_use]
300    pub fn estimate_proportion(p_true: f64, n: u64, k: u64) -> f64 {
301        if n == 0 {
302            return 0.0;
303        }
304        let inv_n = 1.0 / u64_f64(n);
305        let observed_rate = u64_f64(k) * inv_n;
306        // Debiasing: true_rate = (observed_rate - 0.5*(1-p)) / (p - 0.5*(1-p))
307        // Simplifies to: true_rate = (observed_rate - 0.5 + 0.5*p) / (p - 0.5 + 0.5*p)
308        //              = (2*observed_rate - 1 + p) / (2p - 1 + p)
309        //              = (2*observed_rate - 1 + p) / (3p - 1)
310        // Wait, let me recalculate...
311        // P(report=1) = p*true_rate + (1-p)*0.5
312        // observed_rate = p*true_rate + 0.5 - 0.5*p
313        // true_rate = (observed_rate - 0.5 + 0.5*p) / p
314        let inv_p_true = 1.0 / p_true;
315        let true_rate = 0.5f64.mul_add(p_true, observed_rate - 0.5) * inv_p_true;
316        true_rate.clamp(0.0, 1.0)
317    }
318}
319
320// ============================================================================
321// RAPPOR (Randomized Aggregatable Privacy-Preserving Ordinal Response)
322// ============================================================================
323
324/// Default RAPPOR bit size
325pub const RAPPOR_BITS: usize = 64;
326
327/// RAPPOR for categorical data with multiple bits
328///
329/// Encodes categorical values into a Bloom filter, then applies
330/// randomized response to each bit.
331#[derive(Clone, Debug)]
332pub struct Rappor {
333    /// Permanent randomized response (for longitudinal studies)
334    f: f64,
335    /// Instantaneous randomized response parameters
336    p: f64,
337    q: f64,
338    /// PRNG
339    rng: XorShift64,
340}
341
342impl Rappor {
343    /// Number of bits in the Bloom filter
344    pub const BITS: usize = RAPPOR_BITS;
345
346    /// Create a new RAPPOR encoder
347    ///
348    /// # Arguments
349    /// * `f` - Probability of flipping a bit in permanent response (0.0 to 0.5)
350    /// * `p` - Probability of setting a 1 bit to 1 in instantaneous response
351    /// * `q` - Probability of setting a 0 bit to 1 in instantaneous response
352    ///
353    /// `std` only (system entropy); `no_std` は [`Self::with_seed_params`]
354    #[cfg(feature = "std")]
355    #[must_use]
356    pub fn new(f: f64, p: f64, q: f64) -> Self {
357        Self::with_seed_params(f, p, q, XorShift64::from_entropy().next_u64())
358    }
359
360    /// Create with explicit parameters and RNG seed (`no_std` 用、entropy は caller が供給)
361    #[must_use]
362    pub fn with_seed_params(f: f64, p: f64, q: f64, seed: u64) -> Self {
363        Self {
364            f: f.clamp(0.0, 0.5),
365            p: p.clamp(0.0, 1.0),
366            q: q.clamp(0.0, 1.0),
367            rng: XorShift64::new(seed),
368        }
369    }
370
371    /// Create with typical parameters for ε-differential privacy
372    ///
373    /// Uses f=0.5, p=0.75, q=0.25 for approximately ε=2 privacy
374    #[cfg(feature = "std")]
375    #[must_use]
376    pub fn default_params() -> Self {
377        Self::new(0.5, 0.75, 0.25)
378    }
379
380    /// Encode a value into a Bloom filter (simple hash-based)
381    #[allow(clippy::unused_self)]
382    fn encode_bloom(&self, value: u64) -> [u8; RAPPOR_BITS] {
383        use crate::sketch::FnvHasher;
384
385        let mut bloom = [0u8; RAPPOR_BITS];
386        // Use multiple hash functions
387        for i in 0..3u128 {
388            let h = FnvHasher::hash_u128(u128::from(value) | (i << 64));
389            let idx = hash_index(h) % RAPPOR_BITS;
390            bloom[idx] = 1;
391        }
392        bloom
393    }
394
395    /// Apply permanent randomized response
396    fn permanent_response(&mut self, bloom: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
397        let mut result = [0u8; RAPPOR_BITS];
398        for i in 0..RAPPOR_BITS {
399            if self.rng.next_bool(self.f) {
400                // Flip with probability f
401                result[i] = u8::from(self.rng.next_bool(0.5));
402            } else {
403                // Keep original
404                result[i] = bloom[i];
405            }
406        }
407        result
408    }
409
410    /// Apply instantaneous randomized response
411    fn instantaneous_response(&mut self, permanent: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
412        let mut result = [0u8; RAPPOR_BITS];
413        for i in 0..RAPPOR_BITS {
414            if permanent[i] == 1 {
415                result[i] = u8::from(self.rng.next_bool(self.p));
416            } else {
417                result[i] = u8::from(self.rng.next_bool(self.q));
418            }
419        }
420        result
421    }
422
423    /// Encode and privatize a value
424    ///
425    /// Returns the privatized bit array that can be sent to the aggregator.
426    pub fn privatize(&mut self, value: u64) -> [u8; RAPPOR_BITS] {
427        let bloom = self.encode_bloom(value);
428        let permanent = self.permanent_response(&bloom);
429        self.instantaneous_response(&permanent)
430    }
431
432    /// Get privacy parameters
433    #[must_use]
434    pub const fn params(&self) -> (f64, f64, f64) {
435        (self.f, self.p, self.q)
436    }
437}
438
439// ============================================================================
440// Privacy Budget Tracker
441// ============================================================================
442
443/// Track cumulative privacy budget (composition theorem)
444///
445/// Under sequential composition, ε values add up.
446/// Under parallel composition over disjoint data, use max.
447#[derive(Clone, Debug)]
448pub struct PrivacyBudget {
449    /// Total epsilon spent
450    total_epsilon: f64,
451    /// Maximum allowed epsilon
452    max_epsilon: f64,
453    /// Number of queries made
454    query_count: u64,
455}
456
457impl PrivacyBudget {
458    /// Create a new privacy budget tracker
459    #[must_use]
460    pub const fn new(max_epsilon: f64) -> Self {
461        Self {
462            total_epsilon: 0.0,
463            max_epsilon,
464            query_count: 0,
465        }
466    }
467
468    /// Try to spend epsilon from budget
469    ///
470    /// Returns `true` and charges the ledger if the request is well formed and
471    /// fits in the remaining budget; returns `false` and **leaves every field
472    /// untouched** otherwise.
473    ///
474    /// A request is well formed when `epsilon` is finite and not negative.
475    /// `0.0` (and `-0.0`) is well formed: it charges nothing and counts a
476    /// query.
477    ///
478    /// Rejecting a negative `epsilon` is the whole point of the type. Until
479    /// 2026-10-07 the guard was only the budget comparison
480    /// `total + epsilon <= max`, which a negative value passes trivially, so
481    /// `try_spend(-10.0)` returned `true` and *raised* the remaining budget
482    /// from 1 to 11; `-inf` raised it to infinity. A caller that spends a
483    /// negative epsilon between two honest queries escapes the limit the
484    /// budget exists to enforce. `NaN` and `+inf` were already refused,
485    /// though only as a side effect of the comparison being false — they are
486    /// now refused by the same explicit check.
487    pub fn try_spend(&mut self, epsilon: f64) -> bool {
488        // `!(epsilon >= 0.0)` would also catch NaN, but spelling both
489        // conditions out keeps the intent readable: a non-finite or negative
490        // request is not a spend at all, and never reaches the ledger.
491        if !epsilon.is_finite() || epsilon < 0.0 {
492            return false;
493        }
494        if self.total_epsilon + epsilon <= self.max_epsilon {
495            self.total_epsilon += epsilon;
496            self.query_count += 1;
497            true
498        } else {
499            false
500        }
501    }
502
503    /// Get remaining budget
504    #[inline]
505    #[must_use]
506    pub fn remaining(&self) -> f64 {
507        (self.max_epsilon - self.total_epsilon).max(0.0)
508    }
509
510    /// Get total spent
511    #[inline]
512    #[must_use]
513    pub const fn spent(&self) -> f64 {
514        self.total_epsilon
515    }
516
517    /// Get query count
518    #[inline]
519    #[must_use]
520    pub const fn query_count(&self) -> u64 {
521        self.query_count
522    }
523
524    /// Check if budget is exhausted
525    #[inline]
526    #[must_use]
527    pub fn is_exhausted(&self) -> bool {
528        self.total_epsilon >= self.max_epsilon
529    }
530
531    /// Reset the budget
532    pub const fn reset(&mut self) {
533        self.total_epsilon = 0.0;
534        self.query_count = 0;
535    }
536}
537
538// ============================================================================
539// Differentially Private Aggregator
540// ============================================================================
541
542/// Aggregates noisy reports and estimates true statistics
543#[derive(Clone, Debug)]
544pub struct PrivateAggregator {
545    /// Sum of noisy values
546    noisy_sum: f64,
547    /// Count of reports
548    count: u64,
549    /// Laplace noise scale used
550    noise_scale: f64,
551}
552
553impl PrivateAggregator {
554    /// Create a new aggregator
555    #[must_use]
556    pub const fn new(noise_scale: f64) -> Self {
557        Self {
558            noisy_sum: 0.0,
559            count: 0,
560            noise_scale,
561        }
562    }
563
564    /// Add a noisy report
565    #[inline]
566    pub fn add(&mut self, noisy_value: f64) {
567        self.noisy_sum += noisy_value;
568        self.count += 1;
569    }
570
571    /// Estimate the true mean
572    ///
573    /// As count increases, noise averages out to zero.
574    #[must_use]
575    pub fn estimate_mean(&self) -> f64 {
576        if self.count == 0 {
577            0.0
578        } else {
579            let inv_count = 1.0 / u64_f64(self.count);
580            self.noisy_sum * inv_count
581        }
582    }
583
584    /// Estimate the true sum
585    #[must_use]
586    pub const fn estimate_sum(&self) -> f64 {
587        self.noisy_sum
588    }
589
590    /// Get the standard error of the mean estimate
591    ///
592    /// SE = scale * sqrt(2) / sqrt(n)
593    #[must_use]
594    pub fn standard_error(&self) -> f64 {
595        if self.count == 0 {
596            f64::INFINITY
597        } else {
598            let inv_sqrt_n = 1.0 / (u64_f64(self.count)).sqrt();
599            self.noise_scale * core::f64::consts::SQRT_2 * inv_sqrt_n
600        }
601    }
602
603    /// Get the count
604    #[inline]
605    #[must_use]
606    pub const fn count(&self) -> u64 {
607        self.count
608    }
609
610    /// Reset the aggregator
611    pub const fn reset(&mut self) {
612        self.noisy_sum = 0.0;
613        self.count = 0;
614    }
615}
616
617// ============================================================================
618// Tests
619// ============================================================================
620
621#[cfg(test)]
622#[allow(clippy::float_cmp)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn test_xorshift() {
628        let mut rng = XorShift64::new(12345);
629        let v1 = rng.next_u64();
630        let v2 = rng.next_u64();
631        assert_ne!(v1, v2);
632
633        // Test reproducibility
634        let mut rng2 = XorShift64::new(12345);
635        assert_eq!(v1, rng2.next_u64());
636    }
637
638    #[test]
639    fn test_laplace_noise() {
640        let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
641
642        // Generate many samples and check mean is close to 0
643        let mut sum = 0.0;
644        let n = 10000;
645        for _ in 0..n {
646            sum += noise.sample();
647        }
648        let mean = sum / f64::from(n);
649        assert!(mean.abs() < 0.1, "mean = {mean}");
650    }
651
652    #[test]
653    fn test_randomized_response() {
654        let mut rr = RandomizedResponse::with_probability(0.75, 42);
655
656        // With high p_true, most responses should match truth
657        let mut correct = 0;
658        let n = 1000;
659        for i in 0..n {
660            let truth = i % 2 == 0;
661            let response = rr.privatize(truth);
662            if response == truth {
663                correct += 1;
664            }
665        }
666
667        // Should be correct more than 50% of the time
668        assert!(correct > n / 2, "correct = {correct}");
669    }
670
671    #[test]
672    fn test_rr_proportion_estimation() {
673        // Simulate: true rate = 0.3, n = 10000
674        let p_true = 0.8;
675        let true_rate = 0.3;
676        let n = 10000u64;
677
678        let mut rr = RandomizedResponse::with_probability(p_true, 42);
679
680        let mut positive_reports = 0u64;
681        for i in 0..n {
682            // Simulate true value with 30% positive rate
683            let truth = (u64_f64(i) / u64_f64(n)) < true_rate;
684            if rr.privatize(truth) {
685                positive_reports += 1;
686            }
687        }
688
689        let estimated = RandomizedResponse::estimate_proportion(p_true, n, positive_reports);
690        // Should be within 0.1 of true rate
691        assert!(
692            (estimated - true_rate).abs() < 0.1,
693            "estimated = {estimated}, true = {true_rate}"
694        );
695    }
696
697    #[test]
698    fn test_privacy_budget() {
699        let mut budget = PrivacyBudget::new(1.0);
700
701        assert!(budget.try_spend(0.3));
702        assert!(budget.try_spend(0.3));
703        assert!(budget.try_spend(0.3));
704        assert!(!budget.try_spend(0.3)); // Would exceed
705
706        assert_eq!(budget.query_count(), 3);
707        assert!((budget.spent() - 0.9).abs() < 0.001);
708    }
709
710    #[test]
711    fn test_private_aggregator() {
712        let noise_scale = 1.0;
713        let mut aggregator = PrivateAggregator::new(noise_scale);
714        let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
715
716        // True values are all 100
717        let true_value = 100.0;
718        let n = 10000;
719
720        for _ in 0..n {
721            let privatized = noise.privatize(true_value);
722            aggregator.add(privatized);
723        }
724
725        let estimated_mean = aggregator.estimate_mean();
726        // Should be close to 100
727        assert!(
728            (estimated_mean - true_value).abs() < 1.0,
729            "estimated = {estimated_mean}"
730        );
731    }
732
733    // `format!` / `Vec` / the entropy-seeded constructors are `std`-only,
734    // so this case cannot run on the `no_std` build
735    #[cfg(feature = "std")]
736    #[test]
737    fn test_rappor() {
738        let mut rappor = Rappor::default_params();
739
740        let encoded1 = rappor.privatize(12345);
741        let encoded2 = rappor.privatize(12345);
742
743        // Different instances should produce different outputs (randomized)
744        // but with same structure (64 bits)
745        assert_eq!(encoded1.len(), 64);
746        assert_eq!(encoded2.len(), 64);
747    }
748
749    #[test]
750    fn test_xorshift_deterministic() {
751        let mut rng1 = XorShift64::new(42);
752        let mut rng2 = XorShift64::new(42);
753        assert_eq!(rng1.next_u64(), rng2.next_u64());
754        assert_eq!(rng1.next_u64(), rng2.next_u64());
755    }
756
757    #[test]
758    fn test_xorshift_zero_seed() {
759        let mut rng = XorShift64::new(0);
760        // Should use fallback seed, not produce zeros
761        assert_ne!(rng.next_u64(), 0);
762    }
763
764    #[test]
765    fn test_xorshift_f64_range() {
766        let mut rng = XorShift64::new(42);
767        for _ in 0..100 {
768            let v = rng.next_f64();
769            assert!((0.0..1.0).contains(&v), "v = {v}");
770        }
771    }
772
773    #[test]
774    fn test_xorshift_f64_range_custom() {
775        let mut rng = XorShift64::new(42);
776        for _ in 0..100 {
777            let v = rng.next_f64_range(10.0, 20.0);
778            assert!((10.0..20.0).contains(&v), "v = {v}");
779        }
780    }
781
782    #[test]
783    fn test_xorshift_bool() {
784        let mut rng = XorShift64::new(42);
785        let mut trues = 0;
786        let n = 10000;
787        for _ in 0..n {
788            if rng.next_bool(0.5) {
789                trues += 1;
790            }
791        }
792        // Should be roughly 50%
793        assert!(trues > 4000 && trues < 6000, "trues = {trues}");
794    }
795
796    // `format!` / `Vec` / the entropy-seeded constructors are `std`-only,
797    // so this case cannot run on the `no_std` build
798    #[cfg(feature = "std")]
799    #[test]
800    fn test_xorshift_default() {
801        let mut rng = XorShift64::default();
802        let v = rng.next_u64();
803        assert_ne!(v, 0);
804    }
805
806    #[test]
807    fn test_laplace_scale() {
808        let noise = LaplaceNoise::with_seed(2.0, 0.5, 42);
809        // scale = sensitivity / epsilon = 2.0 / 0.5 = 4.0
810        assert!((noise.scale() - 4.0).abs() < f64::EPSILON);
811    }
812
813    #[test]
814    fn test_laplace_privatize_int() {
815        let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
816        let result = noise.privatize_int(100);
817        // Should be close to 100 but not exactly
818        assert!((result - 100).abs() < 20, "result = {result}");
819    }
820
821    #[test]
822    fn test_rr_privatize_bit() {
823        let mut rr = RandomizedResponse::with_probability(0.9, 42);
824        let bit = rr.privatize_bit(1);
825        assert!(bit == 0 || bit == 1);
826    }
827
828    #[test]
829    fn test_rr_p_true() {
830        let rr = RandomizedResponse::with_probability(0.8, 42);
831        assert!((rr.p_true() - 0.8).abs() < f64::EPSILON);
832    }
833
834    #[test]
835    fn test_rr_estimate_zero_n() {
836        let result = RandomizedResponse::estimate_proportion(0.8, 0, 0);
837        assert_eq!(result, 0.0);
838    }
839
840    #[test]
841    fn test_privacy_budget_exhausted() {
842        let mut budget = PrivacyBudget::new(1.0);
843        budget.try_spend(0.5);
844        budget.try_spend(0.5);
845        assert!(budget.is_exhausted());
846    }
847
848    #[test]
849    fn test_privacy_budget_remaining() {
850        let mut budget = PrivacyBudget::new(1.0);
851        budget.try_spend(0.3);
852        assert!((budget.remaining() - 0.7).abs() < 0.001);
853    }
854
855    #[test]
856    fn test_privacy_budget_reset() {
857        let mut budget = PrivacyBudget::new(1.0);
858        budget.try_spend(0.5);
859        budget.reset();
860        assert_eq!(budget.query_count(), 0);
861        assert!((budget.remaining() - 1.0).abs() < f64::EPSILON);
862    }
863
864    #[test]
865    fn test_private_aggregator_empty() {
866        let agg = PrivateAggregator::new(1.0);
867        assert_eq!(agg.estimate_mean(), 0.0);
868        assert_eq!(agg.count(), 0);
869        assert!(agg.standard_error().is_infinite());
870    }
871
872    #[test]
873    fn test_private_aggregator_reset() {
874        let mut agg = PrivateAggregator::new(1.0);
875        agg.add(10.0);
876        agg.add(20.0);
877        agg.reset();
878        assert_eq!(agg.count(), 0);
879        assert_eq!(agg.estimate_mean(), 0.0);
880    }
881
882    #[test]
883    fn test_private_aggregator_sum() {
884        let mut agg = PrivateAggregator::new(1.0);
885        agg.add(10.0);
886        agg.add(20.0);
887        assert!((agg.estimate_sum() - 30.0).abs() < f64::EPSILON);
888    }
889
890    // `format!` / `Vec` / the entropy-seeded constructors are `std`-only,
891    // so this case cannot run on the `no_std` build
892    #[cfg(feature = "std")]
893    #[test]
894    fn test_rappor_params() {
895        let rappor = Rappor::new(0.3, 0.7, 0.2);
896        let (f, p, q) = rappor.params();
897        assert!((f - 0.3).abs() < f64::EPSILON);
898        assert!((p - 0.7).abs() < f64::EPSILON);
899        assert!((q - 0.2).abs() < f64::EPSILON);
900    }
901}