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//! # 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}