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