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//! # Examples
7//!
8//! ```
9//! use alice_physics::privacy::{LaplaceNoise, Rappor, RandomizedResponse, PrivacyBudget};
10//!
11//! // Laplace noise for numeric values
12//! let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
13//! let noisy_value = noise.privatize(100.0);
14//! // Value is perturbed but within a reasonable range
15//! assert!((noisy_value - 100.0).abs() < 100.0);
16//!
17//! // RAPPOR for categorical data
18//! let mut rappor = Rappor::new(0.5, 0.75, 0.25);
19//! let report = rappor.privatize(12345);
20//! assert_eq!(report.len(), 64);
21//!
22//! // Privacy budget tracking
23//! let mut budget = PrivacyBudget::new(10.0);
24//! assert!(budget.try_spend(1.0));
25//! assert_eq!(budget.remaining(), 9.0);
26//! ```
27
28// ============================================================================
29// Random Number Generation (ChaCha20-based for determinism)
30// ============================================================================
31
32/// Simple xorshift64 PRNG for fast random numbers
33///
34// LIMITATION(COV-ENGINE-101): Not cryptographically secure, but fast and sufficient for noise injection.
35/// Not cryptographically secure, but fast and sufficient for noise injection.
36#[derive(Clone, Debug)]
37pub struct XorShift64 {
38 state: u64,
39}
40
41impl XorShift64 {
42 /// Create a new PRNG with given seed
43 ///
44 /// The seed is scrambled with one splitmix64 step before it becomes the
45 /// xorshift state, so nearby seeds (1, 2, 3, ...) start from unrelated
46 /// states and their first draws are not correlated or tiny
47 /// (AUD-A-S4W3-030). Without this, a small seed is a state with only a few
48 /// low bits set and the first outputs of every small seed are below 1e-6.
49 ///
50 /// The step is the splitmix64 output function: add the golden gamma
51 /// `0x9E37_79B9_7F4A_7C15`, then the finaliser
52 /// `z ^= z >> 30; z *= 0xBF58_476D_1CE4_E5B9; z ^= z >> 27;
53 /// z *= 0x94D0_49BB_1331_11EB; z ^= z >> 31` (wrapping). Source: G. L.
54 /// Steele Jr., D. Lea, C. H. Flood, "Fast splittable pseudorandom number
55 /// generators", OOPSLA 2014, with the constants of S. Vigna's reference
56 /// implementation `splitmix64.c` (<https://prng.di.unimi.it/splitmix64.c>).
57 ///
58 /// xorshift cannot leave the all-zero state. splitmix64 is a bijection on
59 /// `u64`, so exactly one seed scrambles to zero; that seed is mapped to
60 /// the fixed non-zero state `0x853C_49E6_748F_EA9B`, so the state is never
61 /// zero for any seed.
62 #[inline]
63 #[must_use]
64 pub const fn new(seed: u64) -> Self {
65 let z = crate::sketch::splitmix64(seed);
66 Self {
67 state: if z == 0 { 0x853c49e6748fea9b } else { z },
68 }
69 }
70
71 /// Create from system entropy (uses address as seed if no std)
72 ///
73 /// The seed is the wall-clock time hashed with the standard library's
74 /// randomly keyed hasher (its keys come from the operating system), so it
75 /// cannot be recovered by trying the clock values around the call
76 /// (AUD-A-S4W3-029).
77 #[cfg(feature = "std")]
78 #[must_use]
79 pub fn from_entropy() -> Self {
80 use std::collections::hash_map::RandomState;
81 use std::hash::{BuildHasher, Hasher};
82 use std::time::{SystemTime, UNIX_EPOCH};
83 let nanos = SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .map(|d| d.as_nanos() as u64)
86 .unwrap_or(0x853c49e6748fea9b);
87 let mut h = RandomState::new().build_hasher();
88 h.write_u64(nanos);
89 Self::new(h.finish())
90 }
91
92 #[cfg(not(feature = "std"))]
93 pub fn from_entropy() -> Self {
94 // Use a fixed seed in no_std environments
95 Self::new(0x853c49e6748fea9b)
96 }
97
98 /// Generate next u64
99 #[inline]
100 pub fn next_u64(&mut self) -> u64 {
101 let mut x = self.state;
102 x ^= x << 13;
103 x ^= x >> 7;
104 x ^= x << 17;
105 self.state = x;
106 x
107 }
108
109 /// Generate uniform f64 in [0, 1)
110 #[inline]
111 pub fn next_f64(&mut self) -> f64 {
112 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
113 }
114
115 /// Generate uniform f64 in [low, high)
116 #[inline]
117 pub fn next_f64_range(&mut self, low: f64, high: f64) -> f64 {
118 (high - low).mul_add(self.next_f64(), low)
119 }
120
121 /// Generate a random boolean with given probability of true
122 #[inline]
123 pub fn next_bool(&mut self, p: f64) -> bool {
124 self.next_f64() < p
125 }
126}
127
128impl Default for XorShift64 {
129 fn default() -> Self {
130 Self::from_entropy()
131 }
132}
133
134// ============================================================================
135// Laplace Distribution
136// ============================================================================
137
138/// Generate Laplace-distributed random variable
139///
140/// Laplace(μ, b) has PDF: f(x) = (1/2b) * exp(-|x-μ|/b)
141///
142/// Used for ε-differential privacy with sensitivity Δf:
143/// noise scale b = Δf / ε
144#[derive(Clone, Debug)]
145pub struct LaplaceNoise {
146 /// Scale parameter b
147 scale: f64,
148 /// PRNG
149 rng: XorShift64,
150}
151
152/// `sensitivity / epsilon`, refusing an `epsilon` that makes the mechanism
153/// meaningless.
154fn laplace_scale(sensitivity: f64, epsilon: f64) -> f64 {
155 assert!(
156 epsilon.is_finite() && epsilon > 0.0,
157 "Laplace epsilon must be finite and positive, got {epsilon}"
158 );
159 sensitivity / epsilon
160}
161
162impl LaplaceNoise {
163 /// Create a new Laplace noise generator
164 ///
165 /// # Arguments
166 /// * `sensitivity` - Maximum change in output for one input change (Δf)
167 /// * `epsilon` - Privacy parameter ε (smaller = more privacy)
168 ///
169 /// # Panics
170 ///
171 /// Panics unless `epsilon` is finite and positive (`ε = 0` would need
172 /// infinite noise: every sample was ±inf).
173 #[must_use]
174 pub fn new(sensitivity: f64, epsilon: f64) -> Self {
175 let scale = laplace_scale(sensitivity, epsilon);
176 Self {
177 scale,
178 rng: XorShift64::from_entropy(),
179 }
180 }
181
182 /// Create with explicit seed
183 ///
184 /// # Panics
185 ///
186 /// As [`Self::new`]: unless `epsilon` is finite and positive.
187 #[must_use]
188 pub fn with_seed(sensitivity: f64, epsilon: f64, seed: u64) -> Self {
189 let scale = laplace_scale(sensitivity, epsilon);
190 Self {
191 scale,
192 rng: XorShift64::new(seed),
193 }
194 }
195
196 /// Generate Laplace noise
197 #[inline]
198 pub fn sample(&mut self) -> f64 {
199 // Inverse transform sampling: X = μ - b * sign(U - 0.5) * ln(1 - 2|U - 0.5|)
200 // U is drawn from [0, 1); U = 0 would give ln(0) = -inf, so that one value
201 // is moved half a step in (2^-54), every other draw is unchanged
202 let raw = self.rng.next_f64();
203 let raw = if raw == 0.0 {
204 0.5 / (1u64 << 53) as f64
205 } else {
206 raw
207 };
208 let u = raw - 0.5;
209 let sign = if u < 0.0 { -1.0 } else { 1.0 };
210 -sign * self.scale * crate::det_math::ln64(2.0f64.mul_add(-u.abs(), 1.0))
211 }
212
213 /// Add noise to a value
214 #[inline]
215 pub fn privatize(&mut self, value: f64) -> f64 {
216 value + self.sample()
217 }
218
219 /// Add noise to an integer value (rounds result)
220 #[inline]
221 pub fn privatize_int(&mut self, value: i64) -> i64 {
222 (value as f64 + self.sample()).round() as i64
223 }
224
225 /// Get the scale parameter
226 #[inline]
227 #[must_use]
228 pub const fn scale(&self) -> f64 {
229 self.scale
230 }
231}
232
233// ============================================================================
234// Randomized Response (RAPPOR-style)
235// ============================================================================
236
237/// Randomized Response for binary data
238///
239/// Classic technique: to report a sensitive bit b:
240/// - With probability p, report b truthfully
241/// - With probability 1-p, report a random bit
242///
243/// This provides ε-differential privacy where ε = ln((p + 0.5(1-p)) / (0.5(1-p)))
244#[derive(Clone, Debug)]
245pub struct RandomizedResponse {
246 /// Probability of truthful response
247 p_true: f64,
248 /// PRNG
249 rng: XorShift64,
250}
251
252impl RandomizedResponse {
253 /// Create from privacy parameter epsilon
254 ///
255 /// Higher epsilon = more accuracy, less privacy
256 #[must_use]
257 pub fn new(epsilon: f64) -> Self {
258 // Truthful with probability p, otherwise a fair coin: the report
259 // probabilities are (1 + p) / 2 and (1 - p) / 2, so ε = ln((1 + p) / (1 - p))
260 // and p = (e^ε - 1) / (e^ε + 1), written as 1 - 2 / (e^ε + 1) so a large
261 // ε gives 1 rather than ∞/∞ (AUD-A-S4W3-020). A negative ε clamps to 0.
262 let exp_eps = crate::det_math::exp64(epsilon);
263 let p_true = (1.0 - 2.0 / (exp_eps + 1.0)).clamp(0.0, 1.0);
264 Self {
265 p_true,
266 rng: XorShift64::from_entropy(),
267 }
268 }
269
270 /// Create with explicit probability and seed
271 #[must_use]
272 pub fn with_probability(p_true: f64, seed: u64) -> Self {
273 Self {
274 // any p in [0, 1] is a valid mechanism (p < 1/2 is more private,
275 // not invalid), so only the range is enforced (AUD-A-S4W3-022)
276 p_true: p_true.clamp(0.0, 1.0),
277 rng: XorShift64::new(seed),
278 }
279 }
280
281 /// Privatize a boolean value
282 #[inline]
283 pub fn privatize(&mut self, value: bool) -> bool {
284 if self.rng.next_bool(self.p_true) {
285 // Report truthfully
286 value
287 } else {
288 // Report random
289 self.rng.next_bool(0.5)
290 }
291 }
292
293 /// Privatize a bit (0 or 1)
294 #[inline]
295 pub fn privatize_bit(&mut self, bit: u8) -> u8 {
296 u8::from(self.privatize(bit != 0))
297 }
298
299 /// Get the probability of truthful response
300 #[inline]
301 #[must_use]
302 pub const fn p_true(&self) -> f64 {
303 self.p_true
304 }
305
306 /// Estimate true proportion from noisy counts
307 ///
308 /// Given N total responses with K positive responses,
309 /// estimate the true proportion of positive values.
310 ///
311 /// # Panics
312 ///
313 /// Panics unless `0 < p_true ≤ 1`: with `p_true = 0` every report is a coin
314 /// flip and carries no information (the estimate was NaN).
315 #[must_use]
316 pub fn estimate_proportion(p_true: f64, n: u64, k: u64) -> f64 {
317 assert!(
318 p_true > 0.0 && p_true <= 1.0,
319 "randomized response p must be in (0, 1], got {p_true}"
320 );
321 if n == 0 {
322 return 0.0;
323 }
324 let observed_rate = k as f64 / n as f64;
325 // Debiasing: true_rate = (observed_rate - 0.5*(1-p)) / (p - 0.5*(1-p))
326 // Simplifies to: true_rate = (observed_rate - 0.5 + 0.5*p) / (p - 0.5 + 0.5*p)
327 // = (2*observed_rate - 1 + p) / (2p - 1 + p)
328 // = (2*observed_rate - 1 + p) / (3p - 1)
329 // Wait, let me recalculate...
330 // P(report=1) = p*true_rate + (1-p)*0.5
331 // observed_rate = p*true_rate + 0.5 - 0.5*p
332 // true_rate = (observed_rate - 0.5 + 0.5*p) / p
333 let true_rate = 0.5f64.mul_add(p_true, observed_rate - 0.5) / p_true;
334 true_rate.clamp(0.0, 1.0)
335 }
336}
337
338// ============================================================================
339// RAPPOR (Randomized Aggregatable Privacy-Preserving Ordinal Response)
340// ============================================================================
341
342/// Default RAPPOR bit size
343pub const RAPPOR_BITS: usize = 64;
344
345/// RAPPOR for categorical data with multiple bits
346///
347/// Encodes categorical values into a Bloom filter, then applies
348/// randomized response to each bit.
349#[derive(Clone, Debug)]
350pub struct Rappor {
351 /// Permanent randomized response (for longitudinal studies)
352 f: f64,
353 /// Instantaneous randomized response parameters
354 p: f64,
355 q: f64,
356 /// PRNG
357 rng: XorShift64,
358}
359
360impl Rappor {
361 /// Number of bits in the Bloom filter
362 pub const BITS: usize = RAPPOR_BITS;
363
364 /// Create a new RAPPOR encoder
365 ///
366 /// # Arguments
367 /// * `f` - Probability of flipping a bit in permanent response (0.0 to 0.5)
368 /// * `p` - Probability of setting a 1 bit to 1 in instantaneous response
369 /// * `q` - Probability of setting a 0 bit to 1 in instantaneous response
370 #[must_use]
371 pub fn new(f: f64, p: f64, q: f64) -> Self {
372 Self {
373 f: f.clamp(0.0, 0.5),
374 p: p.clamp(0.0, 1.0),
375 q: q.clamp(0.0, 1.0),
376 rng: XorShift64::from_entropy(),
377 }
378 }
379
380 /// Create with typical parameters for ε-differential privacy
381 ///
382 /// Uses f=0.5, p=0.75, q=0.25 for approximately ε=2 privacy
383 #[must_use]
384 pub fn default_params() -> Self {
385 Self::new(0.5, 0.75, 0.25)
386 }
387
388 /// Encode a value into a Bloom filter (simple hash-based)
389 #[allow(clippy::unused_self)]
390 fn encode_bloom(&self, value: u64) -> [u8; RAPPOR_BITS] {
391 use crate::sketch::FnvHasher;
392
393 let mut bloom = [0u8; RAPPOR_BITS];
394 // Use multiple hash functions
395 for i in 0..3 {
396 let h = FnvHasher::hash_u128((value as u128) | ((i as u128) << 64));
397 let idx = (h as usize) % RAPPOR_BITS;
398 bloom[idx] = 1;
399 }
400 bloom
401 }
402
403 /// Apply permanent randomized response
404 fn permanent_response(&mut self, bloom: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
405 let mut result = [0u8; RAPPOR_BITS];
406 for i in 0..RAPPOR_BITS {
407 if self.rng.next_bool(self.f) {
408 // Flip with probability f
409 result[i] = u8::from(self.rng.next_bool(0.5));
410 } else {
411 // Keep original
412 result[i] = bloom[i];
413 }
414 }
415 result
416 }
417
418 /// Apply instantaneous randomized response
419 fn instantaneous_response(&mut self, permanent: &[u8; RAPPOR_BITS]) -> [u8; RAPPOR_BITS] {
420 let mut result = [0u8; RAPPOR_BITS];
421 for i in 0..RAPPOR_BITS {
422 if permanent[i] == 1 {
423 result[i] = u8::from(self.rng.next_bool(self.p));
424 } else {
425 result[i] = u8::from(self.rng.next_bool(self.q));
426 }
427 }
428 result
429 }
430
431 /// Encode and privatize a value
432 ///
433 /// Returns the privatized bit array that can be sent to the aggregator.
434 pub fn privatize(&mut self, value: u64) -> [u8; RAPPOR_BITS] {
435 let bloom = self.encode_bloom(value);
436 let permanent = self.permanent_response(&bloom);
437 self.instantaneous_response(&permanent)
438 }
439
440 /// Get privacy parameters
441 #[must_use]
442 pub const fn params(&self) -> (f64, f64, f64) {
443 (self.f, self.p, self.q)
444 }
445}
446
447// ============================================================================
448// Privacy Budget Tracker
449// ============================================================================
450
451/// Track cumulative privacy budget (composition theorem)
452///
453/// Under sequential composition, ε values add up.
454/// Under parallel composition over disjoint data, use max.
455#[derive(Clone, Debug)]
456pub struct PrivacyBudget {
457 /// Total epsilon spent
458 total_epsilon: f64,
459 /// Maximum allowed epsilon
460 max_epsilon: f64,
461 /// Number of queries made
462 query_count: u64,
463}
464
465impl PrivacyBudget {
466 /// Create a new privacy budget tracker
467 #[must_use]
468 pub const fn new(max_epsilon: f64) -> Self {
469 Self {
470 total_epsilon: 0.0,
471 max_epsilon,
472 query_count: 0,
473 }
474 }
475
476 /// Try to spend epsilon from budget
477 ///
478 /// Returns true if budget allows, false if would exceed.
479 pub fn try_spend(&mut self, epsilon: f64) -> bool {
480 // a negative (or NaN) spend would give budget back (AUD-A-S4W3-021)
481 if epsilon.is_nan() || epsilon < 0.0 {
482 return false;
483 }
484 if self.total_epsilon + epsilon <= self.max_epsilon {
485 self.total_epsilon += epsilon;
486 self.query_count += 1;
487 true
488 } else {
489 false
490 }
491 }
492
493 /// Get remaining budget
494 #[inline]
495 #[must_use]
496 pub fn remaining(&self) -> f64 {
497 (self.max_epsilon - self.total_epsilon).max(0.0)
498 }
499
500 /// Get total spent
501 #[inline]
502 #[must_use]
503 pub const fn spent(&self) -> f64 {
504 self.total_epsilon
505 }
506
507 /// Get query count
508 #[inline]
509 #[must_use]
510 pub const fn query_count(&self) -> u64 {
511 self.query_count
512 }
513
514 /// Check if budget is exhausted
515 #[inline]
516 #[must_use]
517 pub fn is_exhausted(&self) -> bool {
518 self.total_epsilon >= self.max_epsilon
519 }
520
521 /// Reset the budget
522 pub fn reset(&mut self) {
523 self.total_epsilon = 0.0;
524 self.query_count = 0;
525 }
526}
527
528// ============================================================================
529// Differentially Private Aggregator
530// ============================================================================
531
532/// Aggregates noisy reports and estimates true statistics
533#[derive(Clone, Debug)]
534pub struct PrivateAggregator {
535 /// Sum of noisy values
536 noisy_sum: f64,
537 /// Count of reports
538 count: u64,
539 /// Laplace noise scale used
540 noise_scale: f64,
541}
542
543impl PrivateAggregator {
544 /// Create a new aggregator
545 #[must_use]
546 pub const fn new(noise_scale: f64) -> Self {
547 Self {
548 noisy_sum: 0.0,
549 count: 0,
550 noise_scale,
551 }
552 }
553
554 /// Add a noisy report
555 #[inline]
556 pub fn add(&mut self, noisy_value: f64) {
557 self.noisy_sum += noisy_value;
558 self.count += 1;
559 }
560
561 /// Estimate the true mean
562 ///
563 /// As count increases, noise averages out to zero.
564 #[must_use]
565 pub fn estimate_mean(&self) -> f64 {
566 if self.count == 0 {
567 0.0
568 } else {
569 self.noisy_sum / self.count as f64
570 }
571 }
572
573 /// Estimate the true sum
574 #[must_use]
575 pub const fn estimate_sum(&self) -> f64 {
576 self.noisy_sum
577 }
578
579 /// Get the standard error of the mean estimate
580 ///
581 /// SE = scale * sqrt(2) / sqrt(n)
582 #[must_use]
583 pub fn standard_error(&self) -> f64 {
584 if self.count == 0 {
585 f64::INFINITY
586 } else {
587 self.noise_scale * core::f64::consts::SQRT_2 / (self.count as f64).sqrt()
588 }
589 }
590
591 /// Get the count
592 #[inline]
593 #[must_use]
594 pub const fn count(&self) -> u64 {
595 self.count
596 }
597
598 /// Reset the aggregator
599 pub fn reset(&mut self) {
600 self.noisy_sum = 0.0;
601 self.count = 0;
602 }
603}
604
605// ============================================================================
606// Tests
607// ============================================================================
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn test_xorshift() {
615 let mut rng = XorShift64::new(12345);
616 let v1 = rng.next_u64();
617 let v2 = rng.next_u64();
618 assert_ne!(v1, v2);
619
620 // Test reproducibility
621 let mut rng2 = XorShift64::new(12345);
622 assert_eq!(v1, rng2.next_u64());
623 }
624
625 #[test]
626 fn test_laplace_noise() {
627 let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
628
629 // Generate many samples and check mean is close to 0
630 let mut sum = 0.0;
631 let n = 10000;
632 for _ in 0..n {
633 sum += noise.sample();
634 }
635 let mean = sum / n as f64;
636 assert!(mean.abs() < 0.1, "mean = {mean}");
637 }
638
639 #[test]
640 fn test_randomized_response() {
641 let mut rr = RandomizedResponse::with_probability(0.75, 42);
642
643 // With high p_true, most responses should match truth
644 let mut correct = 0;
645 let n = 1000;
646 for i in 0..n {
647 let truth = i % 2 == 0;
648 let response = rr.privatize(truth);
649 if response == truth {
650 correct += 1;
651 }
652 }
653
654 // Should be correct more than 50% of the time
655 assert!(correct > n / 2, "correct = {correct}");
656 }
657
658 #[test]
659 fn test_rr_proportion_estimation() {
660 // Simulate: true rate = 0.3, n = 10000
661 let p_true = 0.8;
662 let true_rate = 0.3;
663 let n = 10000u64;
664
665 let mut rr = RandomizedResponse::with_probability(p_true, 42);
666
667 let mut positive_reports = 0u64;
668 for i in 0..n {
669 // Simulate true value with 30% positive rate
670 let truth = (i as f64 / n as f64) < true_rate;
671 if rr.privatize(truth) {
672 positive_reports += 1;
673 }
674 }
675
676 let estimated = RandomizedResponse::estimate_proportion(p_true, n, positive_reports);
677 // Should be within 0.1 of true rate
678 assert!(
679 (estimated - true_rate).abs() < 0.1,
680 "estimated = {estimated}, true = {true_rate}"
681 );
682 }
683
684 #[test]
685 fn test_privacy_budget() {
686 let mut budget = PrivacyBudget::new(1.0);
687
688 assert!(budget.try_spend(0.3));
689 assert!(budget.try_spend(0.3));
690 assert!(budget.try_spend(0.3));
691 assert!(!budget.try_spend(0.3)); // Would exceed
692
693 assert_eq!(budget.query_count(), 3);
694 assert!((budget.spent() - 0.9).abs() < 0.001);
695 }
696
697 #[test]
698 fn test_private_aggregator() {
699 let noise_scale = 1.0;
700 let mut aggregator = PrivateAggregator::new(noise_scale);
701 let mut noise = LaplaceNoise::with_seed(1.0, 1.0, 42);
702
703 // True values are all 100
704 let true_value = 100.0;
705 let n = 10000;
706
707 for _ in 0..n {
708 let noisy = noise.privatize(true_value);
709 aggregator.add(noisy);
710 }
711
712 let estimated_mean = aggregator.estimate_mean();
713 // Should be close to 100
714 assert!(
715 (estimated_mean - true_value).abs() < 1.0,
716 "estimated = {estimated_mean}"
717 );
718 }
719
720 #[test]
721 fn test_rappor() {
722 let mut rappor = Rappor::default_params();
723
724 let encoded1 = rappor.privatize(12345);
725 let encoded2 = rappor.privatize(12345);
726
727 // Different instances should produce different outputs (randomized)
728 // but with same structure (64 bits)
729 assert_eq!(encoded1.len(), 64);
730 assert_eq!(encoded2.len(), 64);
731 }
732
733 #[test]
734 fn aggregator_estimate_sum_is_exact_sum_of_inputs() {
735 let mut agg = PrivateAggregator::new(0.5);
736 assert!(agg.estimate_sum().abs() < 1e-12, "empty sum must be 0");
737
738 // Dyadic values → the running f64 sum is exact, so the contract
739 // Σ inputs can be checked to ~1 ulp.
740 let inputs = [1.5, -2.25, 4.0, 0.125, 100.0];
741 let mut expected = 0.0;
742 for &v in &inputs {
743 agg.add(v);
744 expected += v;
745 }
746 assert_eq!(agg.count(), inputs.len() as u64);
747 assert!(
748 (agg.estimate_sum() - expected).abs() < 1e-12,
749 "sum={} expected={expected}",
750 agg.estimate_sum()
751 );
752 assert!((agg.estimate_sum() - 103.375).abs() < 1e-12);
753 // mean = sum / n
754 assert!((agg.estimate_mean() - 103.375 / 5.0).abs() < 1e-12);
755
756 agg.reset();
757 assert_eq!(agg.count(), 0);
758 assert!(agg.estimate_sum().abs() < 1e-12);
759 }
760
761 #[test]
762 fn aggregator_standard_error_matches_closed_form() {
763 // SE = scale · √2 / √n
764 let scale = 0.75;
765 let mut agg = PrivateAggregator::new(scale);
766 assert!(agg.standard_error().is_infinite(), "n = 0 → SE must be +∞");
767
768 for n in 1..=16u64 {
769 agg.add(0.0);
770 let expected = scale * core::f64::consts::SQRT_2 / (n as f64).sqrt();
771 let se = agg.standard_error();
772 assert!(
773 (se - expected).abs() < 1e-15,
774 "n={n}: se={se} expected={expected}"
775 );
776 }
777 // n = 2 → SE = scale exactly (√2/√2 = 1); n = 8 → SE = scale / 2
778 let mut two = PrivateAggregator::new(scale);
779 two.add(1.0);
780 two.add(1.0);
781 assert!((two.standard_error() - scale).abs() < 1e-15);
782 let mut eight = PrivateAggregator::new(scale);
783 for _ in 0..8 {
784 eight.add(1.0);
785 }
786 assert!((eight.standard_error() - scale / 2.0).abs() < 1e-15);
787 // SE is independent of the values added, only of n and scale.
788 let mut shifted = PrivateAggregator::new(scale);
789 for v in [10.0, -30.0] {
790 shifted.add(v);
791 }
792 assert!((shifted.standard_error() - two.standard_error()).abs() < 1e-15);
793 }
794
795 #[test]
796 fn budget_is_exhausted_only_when_spent_reaches_max() {
797 let mut b = PrivacyBudget::new(1.0);
798 assert!(!b.is_exhausted());
799 assert!((b.remaining() - 1.0).abs() < 1e-12);
800
801 // Spend in dyadic steps so the running total is exact.
802 assert!(b.try_spend(0.5));
803 assert!(!b.is_exhausted());
804 assert!(b.try_spend(0.25));
805 assert!(!b.is_exhausted());
806 assert!((b.remaining() - 0.25).abs() < 1e-12);
807
808 // Reaching exactly max_epsilon exhausts the budget (>=).
809 assert!(b.try_spend(0.25));
810 assert!(b.is_exhausted());
811 assert!(b.remaining().abs() < 1e-12);
812 assert_eq!(b.query_count(), 3);
813
814 // Once exhausted, any positive spend is refused and state is frozen.
815 assert!(!b.try_spend(1e-9));
816 assert!(b.is_exhausted());
817 assert_eq!(b.query_count(), 3);
818 assert!((b.spent() - 1.0).abs() < 1e-12);
819
820 // A refused spend does not exhaust the budget.
821 let mut c = PrivacyBudget::new(1.0);
822 assert!(!c.try_spend(1.5));
823 assert!(!c.is_exhausted());
824 assert_eq!(c.query_count(), 0);
825
826 // Zero-capacity budget is exhausted from the start.
827 let z = PrivacyBudget::new(0.0);
828 assert!(z.is_exhausted());
829
830 // reset clears exhaustion.
831 b.reset();
832 assert!(!b.is_exhausted());
833 assert_eq!(b.query_count(), 0);
834 }
835}