Skip to main content

alice_physics/
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//! # Which types to use
7//!
8//! The private mechanisms are those of `alice-crypto`, re-exported here with
9//! the `privacy-crypto` feature (off by default, so `std` alone pulls no
10//! entropy source):
11//! keyed ChaCha20 (`SecureRng`), discrete Laplace noise sampled with integer
12//! arithmetic in constant time (`dp_count`, `dp_int`, `dp_sum`, `DpNoise`),
13//! `randomized_response` and `bernoulli_ratio`, plus `KeyedRappor` built on
14//! them (plain names: the items exist only with the feature, so no links).
15//! [`PrivacyBudget`] and [`PrivateAggregator`]
16//! draw no randomness and stay as they are.
17//!
18//! # Security
19//!
20//! [`XorShift64`], [`LaplaceNoise`], [`RandomizedResponse`] and [`Rappor`] are
21//! **not private** and are deprecated for that reason (removal is a later,
22//! breaking release): `XorShift64` hands out its internal state, so one
23//! observed draw determines every later one, and its `from_entropy` seed is
24//! derived from the clock; `LaplaceNoise::sample` uses the floating-point
25//! inverse transform, whose low bits leak the uniform draw (Mironov 2012), so
26//! ε does not hold even with a perfect random source.
27//!
28//! # Examples
29//!
30//! ```
31//! # #[cfg(feature = "privacy-crypto")]
32//! # fn main() {
33//! use alice_physics::privacy::{dp_int, KeyedRappor, PrivacyBudget, SecureRng};
34//!
35//! // integer value with sensitivity 1, ε = 1, from a 32-byte secret key
36//! let mut rng = SecureRng::from_key([7u8; 32]);
37//! let noisy: i64 = dp_int(100, 1, 1.0, &mut rng).unwrap();
38//! assert!((noisy - 100).abs() < 100);
39//!
40//! // RAPPOR for categorical data, probabilities as exact fractions
41//! let mut rappor = KeyedRappor::with_key((1, 2), (3, 4), (1, 4), [9u8; 32]).unwrap();
42//! let report = rappor.privatize(12345);
43//! assert_eq!(report.len(), 64);
44//!
45//! // Privacy budget tracking
46//! let mut budget = PrivacyBudget::new(10.0);
47//! assert!(budget.try_spend(1.0));
48//! assert_eq!(budget.remaining(), 9.0);
49//! # }
50//! # #[cfg(not(feature = "privacy-crypto"))]
51//! # fn main() {}
52//! ```
53
54// the deprecated types refer to each other; the deprecation is for callers
55#![allow(deprecated)]
56
57#[cfg(feature = "privacy-crypto")]
58pub use alice_crypto::dp::{
59    bernoulli_ratio, dp_count, dp_int, dp_sum, randomized_response, DpError, DpNoise, EntropyError,
60    SecureRng, RR_MAX_EPSILON_WHOLE,
61};
62
63// ============================================================================
64// Random Number Generation (ChaCha20-based for determinism)
65// ============================================================================
66
67#[deprecated(
68    since = "2.3.0",
69    note = "not differentially private (predictable xorshift64 noise, clock seed, floating-point inverse transform); use privacy::{dp_int, dp_sum, DpNoise, randomized_response, KeyedRappor}"
70)]
71/// Simple xorshift64 PRNG for fast random numbers
72///
73// LIMITATION(COV-ENGINE-101): Not cryptographically secure, but fast and sufficient for noise injection.
74/// Not cryptographically secure, but fast and sufficient for noise injection.
75#[derive(Clone, Debug)]
76pub struct XorShift64 {
77    state: u64,
78}
79
80impl XorShift64 {
81    /// Create a new PRNG with given seed
82    ///
83    /// The seed is scrambled with one splitmix64 step before it becomes the
84    /// xorshift state, so nearby seeds (1, 2, 3, ...) start from unrelated
85    /// states and their first draws are not correlated or tiny
86    /// (AUD-A-S4W3-030). Without this, a small seed is a state with only a few
87    /// low bits set and the first outputs of every small seed are below 1e-6.
88    ///
89    /// The step is the splitmix64 output function: add the golden gamma
90    /// `0x9E37_79B9_7F4A_7C15`, then the finaliser
91    /// `z ^= z >> 30; z *= 0xBF58_476D_1CE4_E5B9; z ^= z >> 27;
92    /// z *= 0x94D0_49BB_1331_11EB; z ^= z >> 31` (wrapping). Source: G. L.
93    /// Steele Jr., D. Lea, C. H. Flood, "Fast splittable pseudorandom number
94    /// generators", OOPSLA 2014, with the constants of S. Vigna's reference
95    /// implementation `splitmix64.c` (<https://prng.di.unimi.it/splitmix64.c>).
96    ///
97    /// xorshift cannot leave the all-zero state. splitmix64 is a bijection on
98    /// `u64`, so exactly one seed scrambles to zero; that seed is mapped to
99    /// the fixed non-zero state `0x853C_49E6_748F_EA9B`, so the state is never
100    /// zero for any seed.
101    #[inline]
102    #[must_use]
103    pub const fn new(seed: u64) -> Self {
104        let z = crate::sketch::splitmix64(seed);
105        Self {
106            state: if z == 0 { 0x853c49e6748fea9b } else { z },
107        }
108    }
109
110    /// Create from system entropy (uses address as seed if no std)
111    ///
112    /// The seed is the wall-clock time hashed with the standard library's
113    /// randomly keyed hasher (its keys come from the operating system), so it
114    /// cannot be recovered by trying the clock values around the call
115    /// (AUD-A-S4W3-029).
116    #[cfg(feature = "std")]
117    #[must_use]
118    // deprecated and documented as not private: the clock seed is its contract
119    #[allow(clippy::disallowed_methods, clippy::disallowed_types)]
120    pub fn from_entropy() -> Self {
121        use std::collections::hash_map::RandomState;
122        use std::hash::{BuildHasher, Hasher};
123        use std::time::{SystemTime, UNIX_EPOCH};
124        let nanos = SystemTime::now()
125            .duration_since(UNIX_EPOCH)
126            .map(|d| d.as_nanos() as u64)
127            .unwrap_or(0x853c49e6748fea9b);
128        let mut h = RandomState::new().build_hasher();
129        h.write_u64(nanos);
130        Self::new(h.finish())
131    }
132
133    #[cfg(not(feature = "std"))]
134    pub fn from_entropy() -> Self {
135        // Use a fixed seed in no_std environments
136        Self::new(0x853c49e6748fea9b)
137    }
138
139    /// Generate next u64
140    #[inline]
141    pub fn next_u64(&mut self) -> u64 {
142        let mut x = self.state;
143        x ^= x << 13;
144        x ^= x >> 7;
145        x ^= x << 17;
146        self.state = x;
147        x
148    }
149
150    /// Generate uniform f64 in [0, 1)
151    #[inline]
152    pub fn next_f64(&mut self) -> f64 {
153        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
154    }
155
156    /// Generate uniform f64 in [low, high)
157    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
158    #[inline]
159    pub fn next_f64_range(&mut self, low: f64, high: f64) -> f64 {
160        (high - low).mul_add(self.next_f64(), low)
161    }
162
163    /// Generate a random boolean with given probability of true
164    #[inline]
165    pub fn next_bool(&mut self, p: f64) -> bool {
166        self.next_f64() < p
167    }
168}
169
170impl Default for XorShift64 {
171    fn default() -> Self {
172        Self::from_entropy()
173    }
174}
175
176// ============================================================================
177// Laplace Distribution
178// ============================================================================
179
180/// Generate Laplace-distributed random variable
181///
182/// Laplace(μ, b) has PDF: f(x) = (1/2b) * exp(-|x-μ|/b)
183///
184/// Used for ε-differential privacy with sensitivity Δf:
185/// noise scale b = Δf / ε
186#[deprecated(
187    since = "2.3.0",
188    note = "not differentially private (predictable xorshift64 noise, clock seed, floating-point inverse transform); use privacy::{dp_int, dp_sum, DpNoise, randomized_response, KeyedRappor}"
189)]
190// ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
191#[derive(Clone, Debug)]
192pub struct LaplaceNoise {
193    /// Scale parameter b
194    scale: f64,
195    /// PRNG
196    rng: XorShift64,
197}
198
199/// `sensitivity / epsilon`, refusing an `epsilon` that makes the mechanism
200/// meaningless.
201fn laplace_scale(sensitivity: f64, epsilon: f64) -> f64 {
202    assert!(
203        epsilon.is_finite() && epsilon > 0.0,
204        "Laplace epsilon must be finite and positive, got {epsilon}"
205    );
206    sensitivity / epsilon
207}
208
209impl LaplaceNoise {
210    /// Create a new Laplace noise generator
211    ///
212    /// # Arguments
213    /// * `sensitivity` - Maximum change in output for one input change (Δf)
214    /// * `epsilon` - Privacy parameter ε (smaller = more privacy)
215    ///
216    /// # Panics
217    ///
218    /// Panics unless `epsilon` is finite and positive (`ε = 0` would need
219    /// infinite noise: every sample was ±inf).
220    #[must_use]
221    pub fn new(sensitivity: f64, epsilon: f64) -> Self {
222        let scale = laplace_scale(sensitivity, epsilon);
223        Self {
224            scale,
225            rng: XorShift64::from_entropy(),
226        }
227    }
228
229    /// Create with explicit seed
230    ///
231    /// # Panics
232    ///
233    /// As [`Self::new`]: unless `epsilon` is finite and positive.
234    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
235    #[must_use]
236    pub fn with_seed(sensitivity: f64, epsilon: f64, seed: u64) -> Self {
237        let scale = laplace_scale(sensitivity, epsilon);
238        Self {
239            scale,
240            rng: XorShift64::new(seed),
241        }
242    }
243
244    /// Generate Laplace noise
245    #[inline]
246    pub fn sample(&mut self) -> f64 {
247        // Inverse transform sampling: X = μ - b * sign(U - 0.5) * ln(1 - 2|U - 0.5|)
248        // U is drawn from [0, 1); U = 0 would give ln(0) = -inf, so that one value
249        // is moved half a step in (2^-54), every other draw is unchanged
250        let raw = self.rng.next_f64();
251        let raw = if raw == 0.0 {
252            0.5 / (1u64 << 53) as f64
253        } else {
254            raw
255        };
256        let u = raw - 0.5;
257        let sign = if u < 0.0 { -1.0 } else { 1.0 };
258        -sign * self.scale * crate::det_math::ln64(2.0f64.mul_add(-u.abs(), 1.0))
259    }
260
261    /// Add noise to a value
262    #[inline]
263    pub fn privatize(&mut self, value: f64) -> f64 {
264        value + self.sample()
265    }
266
267    /// Add noise to an integer value (rounds result)
268    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
269    #[inline]
270    pub fn privatize_int(&mut self, value: i64) -> i64 {
271        (value as f64 + self.sample()).round() as i64
272    }
273
274    /// Get the scale parameter
275    #[inline]
276    #[must_use]
277    pub const fn scale(&self) -> f64 {
278        self.scale
279    }
280}
281
282// ============================================================================
283// Randomized Response (RAPPOR-style)
284// ============================================================================
285
286/// Randomized Response for binary data
287///
288/// Classic technique: to report a sensitive bit b:
289/// - With probability p, report b truthfully
290/// - With probability 1-p, report a random bit
291///
292/// This provides ε-differential privacy where ε = ln((p + 0.5(1-p)) / (0.5(1-p)))
293#[deprecated(
294    since = "2.3.0",
295    note = "not differentially private (predictable xorshift64 noise, clock seed, floating-point inverse transform); use privacy::{dp_int, dp_sum, DpNoise, randomized_response, KeyedRappor}"
296)]
297// ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
298#[derive(Clone, Debug)]
299pub struct RandomizedResponse {
300    /// Probability of truthful response
301    p_true: f64,
302    /// PRNG
303    rng: XorShift64,
304}
305
306impl RandomizedResponse {
307    /// Create from privacy parameter epsilon
308    ///
309    /// Higher epsilon = more accuracy, less privacy
310    #[must_use]
311    pub fn new(epsilon: f64) -> Self {
312        // Truthful with probability p, otherwise a fair coin: the report
313        // probabilities are (1 + p) / 2 and (1 - p) / 2, so ε = ln((1 + p) / (1 - p))
314        // and p = (e^ε - 1) / (e^ε + 1), written as 1 - 2 / (e^ε + 1) so a large
315        // ε gives 1 rather than ∞/∞ (AUD-A-S4W3-020). A negative ε clamps to 0.
316        let exp_eps = crate::det_math::exp64(epsilon);
317        let p_true = (1.0 - 2.0 / (exp_eps + 1.0)).clamp(0.0, 1.0);
318        Self {
319            p_true,
320            rng: XorShift64::from_entropy(),
321        }
322    }
323
324    /// Create with explicit probability and seed
325    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
326    #[must_use]
327    pub fn with_probability(p_true: f64, seed: u64) -> Self {
328        Self {
329            // any p in [0, 1] is a valid mechanism (p < 1/2 is more private,
330            // not invalid), so only the range is enforced (AUD-A-S4W3-022)
331            p_true: p_true.clamp(0.0, 1.0),
332            rng: XorShift64::new(seed),
333        }
334    }
335
336    /// Privatize a boolean value
337    #[inline]
338    pub fn privatize(&mut self, value: bool) -> bool {
339        if self.rng.next_bool(self.p_true) {
340            // Report truthfully
341            value
342        } else {
343            // Report random
344            self.rng.next_bool(0.5)
345        }
346    }
347
348    /// Privatize a bit (0 or 1)
349    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
350    #[inline]
351    pub fn privatize_bit(&mut self, bit: u8) -> u8 {
352        u8::from(self.privatize(bit != 0))
353    }
354
355    /// Get the probability of truthful response
356    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
357    #[inline]
358    #[must_use]
359    pub const fn p_true(&self) -> f64 {
360        self.p_true
361    }
362
363    /// Estimate true proportion from noisy counts
364    ///
365    /// Given N total responses with K positive responses,
366    /// estimate the true proportion of positive values.
367    ///
368    /// # Panics
369    ///
370    /// Panics unless `0 < p_true ≤ 1`: with `p_true = 0` every report is a coin
371    /// flip and carries no information (the estimate was NaN).
372    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
373    #[must_use]
374    pub fn estimate_proportion(p_true: f64, n: u64, k: u64) -> f64 {
375        assert!(
376            p_true > 0.0 && p_true <= 1.0,
377            "randomized response p must be in (0, 1], got {p_true}"
378        );
379        if n == 0 {
380            return 0.0;
381        }
382        let observed_rate = k as f64 / n as f64;
383        // Debiasing: true_rate = (observed_rate - 0.5*(1-p)) / (p - 0.5*(1-p))
384        // Simplifies to: true_rate = (observed_rate - 0.5 + 0.5*p) / (p - 0.5 + 0.5*p)
385        //              = (2*observed_rate - 1 + p) / (2p - 1 + p)
386        //              = (2*observed_rate - 1 + p) / (3p - 1)
387        // Wait, let me recalculate...
388        // P(report=1) = p*true_rate + (1-p)*0.5
389        // observed_rate = p*true_rate + 0.5 - 0.5*p
390        // true_rate = (observed_rate - 0.5 + 0.5*p) / p
391        let true_rate = 0.5f64.mul_add(p_true, observed_rate - 0.5) / p_true;
392        true_rate.clamp(0.0, 1.0)
393    }
394}
395
396// ============================================================================
397// RAPPOR (Randomized Aggregatable Privacy-Preserving Ordinal Response)
398// ============================================================================
399
400/// Default RAPPOR bit size
401pub const RAPPOR_BITS: usize = 64;
402
403/// RAPPOR for categorical data with multiple bits
404///
405/// Encodes categorical values into a Bloom filter, then applies
406/// randomized response to each bit.
407#[deprecated(
408    since = "2.3.0",
409    note = "not differentially private (predictable xorshift64 noise, clock seed, floating-point inverse transform); use privacy::{dp_int, dp_sum, DpNoise, randomized_response, KeyedRappor}"
410)]
411// ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
412#[derive(Clone, Debug)]
413pub struct Rappor {
414    /// Permanent randomized response (for longitudinal studies)
415    f: f64,
416    /// Instantaneous randomized response parameters
417    p: f64,
418    q: f64,
419    /// PRNG
420    rng: XorShift64,
421}
422
423impl Rappor {
424    /// Number of bits in the Bloom filter
425    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
426    pub const BITS: usize = RAPPOR_BITS;
427
428    /// Create a new RAPPOR encoder
429    ///
430    /// # Arguments
431    /// * `f` - Probability of flipping a bit in permanent response (0.0 to 0.5)
432    /// * `p` - Probability of setting a 1 bit to 1 in instantaneous response
433    /// * `q` - Probability of setting a 0 bit to 1 in instantaneous response
434    #[must_use]
435    pub fn new(f: f64, p: f64, q: f64) -> Self {
436        Self {
437            f: f.clamp(0.0, 0.5),
438            p: p.clamp(0.0, 1.0),
439            q: q.clamp(0.0, 1.0),
440            rng: XorShift64::from_entropy(),
441        }
442    }
443
444    /// Create with typical parameters for ε-differential privacy
445    ///
446    /// Uses f=0.5, p=0.75, q=0.25 for approximately ε=2 privacy
447    // ALLOW-UNWIRED: wiring debt deprecated-privacy (deprecated, not private: kept for existing callers until the breaking release removes it), oracle tests/audit_privacy.rs
448    #[must_use]
449    pub fn default_params() -> Self {
450        Self::new(0.5, 0.75, 0.25)
451    }
452
453    /// Encode a value into a Bloom filter (simple hash-based)
454    #[allow(clippy::unused_self)]
455    fn encode_bloom(&self, value: u64) -> [u8; RAPPOR_BITS] {
456        bloom_bits(value)
457    }
458
459    /// Apply permanent randomized response
460    fn permanent_response(&mut self, bloom: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
461        let mut result = [0u8; RAPPOR_BITS];
462        for i in 0..RAPPOR_BITS {
463            if self.rng.next_bool(self.f) {
464                // Flip with probability f
465                result[i] = u8::from(self.rng.next_bool(0.5));
466            } else {
467                // Keep original
468                result[i] = bloom[i];
469            }
470        }
471        result
472    }
473
474    /// Apply instantaneous randomized response
475    fn instantaneous_response(&mut self, permanent: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
476        let mut result = [0u8; RAPPOR_BITS];
477        for i in 0..RAPPOR_BITS {
478            if permanent[i] == 1 {
479                result[i] = u8::from(self.rng.next_bool(self.p));
480            } else {
481                result[i] = u8::from(self.rng.next_bool(self.q));
482            }
483        }
484        result
485    }
486
487    /// Encode and privatize a value
488    ///
489    /// Returns the privatized bit array that can be sent to the aggregator.
490    pub fn privatize(&mut self, value: u64) -> [u8; RAPPOR_BITS] {
491        let bloom = self.encode_bloom(value);
492        let permanent = self.permanent_response(&bloom);
493        self.instantaneous_response(&permanent)
494    }
495
496    /// Get privacy parameters
497    #[must_use]
498    pub const fn params(&self) -> (f64, f64, f64) {
499        (self.f, self.p, self.q)
500    }
501}
502
503// ============================================================================
504// Keyed RAPPOR
505// ============================================================================
506
507/// RAPPOR with a keyed, unpredictable noise source and exact probabilities
508///
509/// Each probability is a fraction `(num, den)`: `f` for the permanent
510/// response (at most 1/2), `p` and `q` for reporting a 1 when the permanent
511/// bit is 1 or 0. Every bit draws the same four Bernoulli trials from the
512/// keyed ChaCha20 stream ([`bernoulli_ratio`]) and the result is chosen by
513/// arithmetic, so the work and the keystream use do not depend on the value
514/// being reported. The Bloom encoding is the one [`Rappor`] uses.
515#[cfg(feature = "privacy-crypto")]
516pub struct KeyedRappor {
517    f: (u64, u64),
518    p: (u64, u64),
519    q: (u64, u64),
520    rng: SecureRng,
521}
522
523#[cfg(feature = "privacy-crypto")]
524impl core::fmt::Debug for KeyedRappor {
525    fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
526        fmt.debug_struct("KeyedRappor")
527            .field("f", &self.f)
528            .field("p", &self.p)
529            .field("q", &self.q)
530            .finish_non_exhaustive()
531    }
532}
533
534#[cfg(feature = "privacy-crypto")]
535impl KeyedRappor {
536    /// A RAPPOR encoder whose noise comes from the 32-byte secret `key`
537    ///
538    /// # Errors
539    ///
540    /// [`DpError::InvalidProbability`] when a fraction has `den == 0` or
541    /// `num > den`, or when `f` exceeds 1/2.
542    pub fn with_key(
543        f: (u64, u64),
544        p: (u64, u64),
545        q: (u64, u64),
546        key: [u8; 32],
547    ) -> Result<Self, DpError> {
548        for (num, den) in [f, p, q] {
549            if den == 0 || num > den {
550                return Err(DpError::InvalidProbability);
551            }
552        }
553        if u128::from(f.0) * 2 > u128::from(f.1) {
554            return Err(DpError::InvalidProbability);
555        }
556        Ok(Self {
557            f,
558            p,
559            q,
560            rng: SecureRng::from_key(key),
561        })
562    }
563
564    /// The probabilities `(f, p, q)` as given
565    #[must_use]
566    pub const fn params(&self) -> ((u64, u64), (u64, u64), (u64, u64)) {
567        (self.f, self.p, self.q)
568    }
569
570    /// Encode and privatize a value: Bloom encoding, permanent response
571    /// (each bit replaced by a fair coin with probability `f`), instantaneous
572    /// response (a 1 reported with probability `p` for a 1 and `q` for a 0)
573    pub fn privatize(&mut self, value: u64) -> [u8; RAPPOR_BITS] {
574        let bloom = bloom_bits(value);
575        let mut out = [0u8; RAPPOR_BITS];
576        for (o, &b) in out.iter_mut().zip(bloom.iter()) {
577            let flip = self.coin(self.f);
578            let fair = self.coin((1, 2));
579            let one = self.coin(self.p);
580            let zero = self.coin(self.q);
581            let permanent = (flip & fair) | ((1 - flip) & b);
582            *o = (permanent & one) | ((1 - permanent) & zero);
583        }
584        out
585    }
586
587    fn coin(&mut self, (num, den): (u64, u64)) -> u8 {
588        // the fractions were validated in `with_key`
589        u8::from(bernoulli_ratio(num, den, &mut self.rng).unwrap_or(false))
590    }
591}
592
593/// The Bloom encoding of `value` used by [`Rappor`] and `KeyedRappor` (with `privacy-crypto`):
594/// three FNV hashes, one bit each
595fn bloom_bits(value: u64) -> [u8; RAPPOR_BITS] {
596    use crate::sketch::FnvHasher;
597    let mut bloom = [0u8; RAPPOR_BITS];
598    for i in 0..3u128 {
599        let h = FnvHasher::hash_u128(u128::from(value) | (i << 64));
600        bloom[(h as usize) % RAPPOR_BITS] = 1;
601    }
602    bloom
603}
604
605// ============================================================================
606// Privacy Budget Tracker
607// ============================================================================
608
609/// Track cumulative privacy budget (composition theorem)
610///
611/// Under sequential composition, ε values add up.
612/// Under parallel composition over disjoint data, use max.
613#[derive(Clone, Debug)]
614pub struct PrivacyBudget {
615    /// Total epsilon spent
616    total_epsilon: f64,
617    /// Maximum allowed epsilon
618    max_epsilon: f64,
619    /// Number of queries made
620    query_count: u64,
621}
622
623impl PrivacyBudget {
624    /// Create a new privacy budget tracker
625    #[must_use]
626    pub const fn new(max_epsilon: f64) -> Self {
627        Self {
628            total_epsilon: 0.0,
629            max_epsilon,
630            query_count: 0,
631        }
632    }
633
634    /// Try to spend epsilon from budget
635    ///
636    /// Returns true if budget allows, false if would exceed.
637    pub fn try_spend(&mut self, epsilon: f64) -> bool {
638        // a negative (or NaN) spend would give budget back (AUD-A-S4W3-021)
639        if epsilon.is_nan() || epsilon < 0.0 {
640            return false;
641        }
642        if self.total_epsilon + epsilon <= self.max_epsilon {
643            self.total_epsilon += epsilon;
644            self.query_count += 1;
645            true
646        } else {
647            false
648        }
649    }
650
651    /// Get remaining budget
652    #[inline]
653    #[must_use]
654    pub fn remaining(&self) -> f64 {
655        crate::det_math::max64(self.max_epsilon - self.total_epsilon, 0.0)
656    }
657
658    /// Get total spent
659    #[inline]
660    #[must_use]
661    pub const fn spent(&self) -> f64 {
662        self.total_epsilon
663    }
664
665    /// Get query count
666    #[inline]
667    #[must_use]
668    pub const fn query_count(&self) -> u64 {
669        self.query_count
670    }
671
672    /// Check if budget is exhausted
673    #[inline]
674    #[must_use]
675    pub fn is_exhausted(&self) -> bool {
676        self.total_epsilon >= self.max_epsilon
677    }
678
679    /// Reset the budget
680    pub fn reset(&mut self) {
681        self.total_epsilon = 0.0;
682        self.query_count = 0;
683    }
684}
685
686// ============================================================================
687// Differentially Private Aggregator
688// ============================================================================
689
690/// Aggregates noisy reports and estimates true statistics
691#[derive(Clone, Debug)]
692pub struct PrivateAggregator {
693    /// Sum of noisy values
694    noisy_sum: f64,
695    /// Count of reports
696    count: u64,
697    /// Laplace noise scale used
698    noise_scale: f64,
699}
700
701impl PrivateAggregator {
702    /// Create a new aggregator
703    #[must_use]
704    pub const fn new(noise_scale: f64) -> Self {
705        Self {
706            noisy_sum: 0.0,
707            count: 0,
708            noise_scale,
709        }
710    }
711
712    /// Add a noisy report
713    #[inline]
714    pub fn add(&mut self, noisy_value: f64) {
715        self.noisy_sum += noisy_value;
716        self.count += 1;
717    }
718
719    /// Estimate the true mean
720    ///
721    /// As count increases, noise averages out to zero.
722    #[must_use]
723    pub fn estimate_mean(&self) -> f64 {
724        if self.count == 0 {
725            0.0
726        } else {
727            self.noisy_sum / self.count as f64
728        }
729    }
730
731    /// Estimate the true sum
732    #[must_use]
733    pub const fn estimate_sum(&self) -> f64 {
734        self.noisy_sum
735    }
736
737    /// Get the standard error of the mean estimate
738    ///
739    /// SE = scale * sqrt(2) / sqrt(n)
740    #[must_use]
741    pub fn standard_error(&self) -> f64 {
742        if self.count == 0 {
743            f64::INFINITY
744        } else {
745            self.noise_scale * core::f64::consts::SQRT_2 / (self.count as f64).sqrt()
746        }
747    }
748
749    /// Get the count
750    #[inline]
751    #[must_use]
752    pub const fn count(&self) -> u64 {
753        self.count
754    }
755
756    /// Reset the aggregator
757    pub fn reset(&mut self) {
758        self.noisy_sum = 0.0;
759        self.count = 0;
760    }
761}
762
763// ============================================================================
764// Tests
765// ============================================================================
766
767#[cfg(all(test, feature = "privacy-crypto"))]
768mod keyed_rappor_tests {
769    use super::{bloom_bits, DpError, KeyedRappor, RAPPOR_BITS};
770
771    /// With `f = 1/2, p = 3/4, q = 1/4` a Bloom bit of 1 is reported as 1 with
772    /// probability `(1 − f)·p + f·(p + q)/2 = 5/8` and a 0 with `3/8` (closed
773    /// form from the RAPPOR definition); 4000 reports of one value, each
774    /// frequency within 6 standard errors
775    #[test]
776    fn report_frequencies_match_the_closed_form() {
777        let mut r = KeyedRappor::with_key((1, 2), (3, 4), (1, 4), [3u8; 32]).unwrap();
778        let bloom = bloom_bits(777);
779        let n = 4000u32;
780        let mut ones = [0u32; RAPPOR_BITS];
781        for _ in 0..n {
782            for (c, &bit) in ones.iter_mut().zip(r.privatize(777).iter()) {
783                *c += u32::from(bit);
784            }
785        }
786        let (mut set_ones, mut set_n, mut clear_ones, mut clear_n) = (0u32, 0u32, 0u32, 0u32);
787        for i in 0..RAPPOR_BITS {
788            if bloom[i] == 1 {
789                set_ones += ones[i];
790                set_n += n;
791            } else {
792                clear_ones += ones[i];
793                clear_n += n;
794            }
795        }
796        for (k, m, p) in [
797            (set_ones, set_n, 5.0 / 8.0),
798            (clear_ones, clear_n, 3.0 / 8.0),
799        ] {
800            let mean = p * f64::from(m);
801            let sd = (f64::from(m) * p * (1.0 - p)).sqrt();
802            assert!(
803                (f64::from(k) - mean).abs() <= 6.0 * sd,
804                "{k} of {m}, expected {mean}"
805            );
806        }
807    }
808
809    #[test]
810    fn f_zero_p_one_q_zero_reports_the_bloom_filter() {
811        let mut r = KeyedRappor::with_key((0, 1), (1, 1), (0, 1), [5u8; 32]).unwrap();
812        for v in [0u64, 1, 12345, u64::MAX] {
813            assert_eq!(r.privatize(v), bloom_bits(v));
814        }
815    }
816
817    #[test]
818    fn the_same_key_replays_and_another_key_differs() {
819        let run = |key| {
820            let mut r = KeyedRappor::with_key((1, 2), (3, 4), (1, 4), key).unwrap();
821            (0..16).map(|v| r.privatize(v)).collect::<Vec<_>>()
822        };
823        assert_eq!(run([1u8; 32]), run([1u8; 32]));
824        assert_ne!(run([1u8; 32]), run([2u8; 32]));
825    }
826
827    #[test]
828    fn impossible_fractions_and_f_over_one_half_are_refused() {
829        let k = [0u8; 32];
830        for (f, p, q) in [
831            ((1, 0), (1, 2), (1, 2)),
832            ((1, 2), (3, 2), (1, 2)),
833            ((1, 2), (1, 2), (1, 0)),
834            ((3, 5), (1, 2), (1, 2)),
835        ] {
836            assert_eq!(
837                KeyedRappor::with_key(f, p, q, k).map(|r| r.params()),
838                Err(DpError::InvalidProbability),
839                "{f:?} {p:?} {q:?}"
840            );
841        }
842        assert!(KeyedRappor::with_key((1, 2), (0, 1), (1, 1), k).is_ok());
843        assert!(format!(
844            "{:?}",
845            KeyedRappor::with_key((1, 2), (1, 2), (1, 2), k).unwrap()
846        )
847        .starts_with("KeyedRappor"));
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_xorshift() {
857        let mut rng = XorShift64::new(12345);
858        let v1 = rng.next_u64();
859        let v2 = rng.next_u64();
860        assert_ne!(v1, v2);
861
862        // Test reproducibility
863        let mut rng2 = XorShift64::new(12345);
864        assert_eq!(v1, rng2.next_u64());
865    }
866
867    #[test]
868    fn test_laplace_noise() {
869        let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
870
871        // Generate many samples and check mean is close to 0
872        let mut sum = 0.0;
873        let n = 10000;
874        for _ in 0..n {
875            sum += noise.sample();
876        }
877        let mean = sum / n as f64;
878        assert!(mean.abs() < 0.1, "mean = {mean}");
879    }
880
881    #[test]
882    fn test_randomized_response() {
883        let mut rr = RandomizedResponse::with_probability(0.75, 42);
884
885        // With high p_true, most responses should match truth
886        let mut correct = 0;
887        let n = 1000;
888        for i in 0..n {
889            let truth = i % 2 == 0;
890            let response = rr.privatize(truth);
891            if response == truth {
892                correct += 1;
893            }
894        }
895
896        // Should be correct more than 50% of the time
897        assert!(correct > n / 2, "correct = {correct}");
898    }
899
900    #[test]
901    fn test_rr_proportion_estimation() {
902        // Simulate: true rate = 0.3, n = 10000
903        let p_true = 0.8;
904        let true_rate = 0.3;
905        let n = 10000u64;
906
907        let mut rr = RandomizedResponse::with_probability(p_true, 42);
908
909        let mut positive_reports = 0u64;
910        for i in 0..n {
911            // Simulate true value with 30% positive rate
912            let truth = (i as f64 / n as f64) < true_rate;
913            if rr.privatize(truth) {
914                positive_reports += 1;
915            }
916        }
917
918        let estimated = RandomizedResponse::estimate_proportion(p_true, n, positive_reports);
919        // Should be within 0.1 of true rate
920        assert!(
921            (estimated - true_rate).abs() < 0.1,
922            "estimated = {estimated}, true = {true_rate}"
923        );
924    }
925
926    #[test]
927    fn test_privacy_budget() {
928        let mut budget = PrivacyBudget::new(1.0);
929
930        assert!(budget.try_spend(0.3));
931        assert!(budget.try_spend(0.3));
932        assert!(budget.try_spend(0.3));
933        assert!(!budget.try_spend(0.3)); // Would exceed
934
935        assert_eq!(budget.query_count(), 3);
936        assert!((budget.spent() - 0.9).abs() < 0.001);
937    }
938
939    #[test]
940    fn test_private_aggregator() {
941        let noise_scale = 1.0;
942        let mut aggregator = PrivateAggregator::new(noise_scale);
943        let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
944
945        // True values are all 100
946        let true_value = 100.0;
947        let n = 10000;
948
949        for _ in 0..n {
950            let noisy = noise.privatize(true_value);
951            aggregator.add(noisy);
952        }
953
954        let estimated_mean = aggregator.estimate_mean();
955        // Should be close to 100
956        assert!(
957            (estimated_mean - true_value).abs() < 1.0,
958            "estimated = {estimated_mean}"
959        );
960    }
961
962    #[test]
963    fn test_rappor() {
964        let mut rappor = Rappor::default_params();
965
966        let encoded1 = rappor.privatize(12345);
967        let encoded2 = rappor.privatize(12345);
968
969        // Different instances should produce different outputs (randomized)
970        // but with same structure (64 bits)
971        assert_eq!(encoded1.len(), 64);
972        assert_eq!(encoded2.len(), 64);
973    }
974
975    #[test]
976    fn aggregator_estimate_sum_is_exact_sum_of_inputs() {
977        let mut agg = PrivateAggregator::new(0.5);
978        assert!(agg.estimate_sum().abs() < 1e-12, "empty sum must be 0");
979
980        // Dyadic values → the running f64 sum is exact, so the contract
981        // Σ inputs can be checked to ~1 ulp.
982        let inputs = [1.5, -2.25, 4.0, 0.125, 100.0];
983        let mut expected = 0.0;
984        for &v in &inputs {
985            agg.add(v);
986            expected += v;
987        }
988        assert_eq!(agg.count(), inputs.len() as u64);
989        assert!(
990            (agg.estimate_sum() - expected).abs() < 1e-12,
991            "sum={} expected={expected}",
992            agg.estimate_sum()
993        );
994        assert!((agg.estimate_sum() - 103.375).abs() < 1e-12);
995        // mean = sum / n
996        assert!((agg.estimate_mean() - 103.375 / 5.0).abs() < 1e-12);
997
998        agg.reset();
999        assert_eq!(agg.count(), 0);
1000        assert!(agg.estimate_sum().abs() < 1e-12);
1001    }
1002
1003    #[test]
1004    fn aggregator_standard_error_matches_closed_form() {
1005        // SE = scale · √2 / √n
1006        let scale = 0.75;
1007        let mut agg = PrivateAggregator::new(scale);
1008        assert!(agg.standard_error().is_infinite(), "n = 0 → SE must be +∞");
1009
1010        for n in 1..=16u64 {
1011            agg.add(0.0);
1012            let expected = scale * core::f64::consts::SQRT_2 / (n as f64).sqrt();
1013            let se = agg.standard_error();
1014            assert!(
1015                (se - expected).abs() < 1e-15,
1016                "n={n}: se={se} expected={expected}"
1017            );
1018        }
1019        // n = 2 → SE = scale exactly (√2/√2 = 1); n = 8 → SE = scale / 2
1020        let mut two = PrivateAggregator::new(scale);
1021        two.add(1.0);
1022        two.add(1.0);
1023        assert!((two.standard_error() - scale).abs() < 1e-15);
1024        let mut eight = PrivateAggregator::new(scale);
1025        for _ in 0..8 {
1026            eight.add(1.0);
1027        }
1028        assert!((eight.standard_error() - scale / 2.0).abs() < 1e-15);
1029        // SE is independent of the values added, only of n and scale.
1030        let mut shifted = PrivateAggregator::new(scale);
1031        for v in [10.0, -30.0] {
1032            shifted.add(v);
1033        }
1034        assert!((shifted.standard_error() - two.standard_error()).abs() < 1e-15);
1035    }
1036
1037    #[test]
1038    fn budget_is_exhausted_only_when_spent_reaches_max() {
1039        let mut b = PrivacyBudget::new(1.0);
1040        assert!(!b.is_exhausted());
1041        assert!((b.remaining() - 1.0).abs() < 1e-12);
1042
1043        // Spend in dyadic steps so the running total is exact.
1044        assert!(b.try_spend(0.5));
1045        assert!(!b.is_exhausted());
1046        assert!(b.try_spend(0.25));
1047        assert!(!b.is_exhausted());
1048        assert!((b.remaining() - 0.25).abs() < 1e-12);
1049
1050        // Reaching exactly max_epsilon exhausts the budget (>=).
1051        assert!(b.try_spend(0.25));
1052        assert!(b.is_exhausted());
1053        assert!(b.remaining().abs() < 1e-12);
1054        assert_eq!(b.query_count(), 3);
1055
1056        // Once exhausted, any positive spend is refused and state is frozen.
1057        assert!(!b.try_spend(1e-9));
1058        assert!(b.is_exhausted());
1059        assert_eq!(b.query_count(), 3);
1060        assert!((b.spent() - 1.0).abs() < 1e-12);
1061
1062        // A refused spend does not exhaust the budget.
1063        let mut c = PrivacyBudget::new(1.0);
1064        assert!(!c.try_spend(1.5));
1065        assert!(!c.is_exhausted());
1066        assert_eq!(c.query_count(), 0);
1067
1068        // Zero-capacity budget is exhausted from the start.
1069        let z = PrivacyBudget::new(0.0);
1070        assert!(z.is_exhausted());
1071
1072        // reset clears exhaustion.
1073        b.reset();
1074        assert!(!b.is_exhausted());
1075        assert_eq!(b.query_count(), 0);
1076    }
1077}