Skip to main content

alice_analytics/
privacy.rs

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