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}