Skip to main content

ipfrs_tensorlogic/
differential_privacy.rs

1//! Differential privacy toolkit for IPFRS.
2//!
3//! Provides noise mechanisms (Laplace, Gaussian), sensitivity analysis, and
4//! privacy budget management for differentially-private data queries.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ipfrs_tensorlogic::differential_privacy::{
10//!     DifferentialPrivacyEngine, DpQuery, PrivacyMechanism,
11//! };
12//!
13//! let mut engine = DifferentialPrivacyEngine::new(10.0, 1e-5, 100);
14//!
15//! let query = DpQuery {
16//!     query_id: "q1".to_string(),
17//!     sensitivity: 1.0,
18//!     mechanism: PrivacyMechanism::Laplace { sensitivity: 1.0, epsilon: 1.0 },
19//! };
20//!
21//! let result = engine.apply_mechanism(&query, 42.0).expect("example: should succeed in docs");
22//! assert_eq!(result.query_id, "q1");
23//! assert!(result.noisy_value.is_finite());
24//! ```
25
26use std::collections::VecDeque;
27use std::f64::consts::PI;
28use thiserror::Error;
29
30// ── xorshift64 PRNG ────────────────────────────────────────────────────────
31
32/// xorshift64 PRNG — fast, deterministic, no external dependencies.
33fn xorshift64(state: &mut u64) -> u64 {
34    let mut x = *state;
35    x ^= x << 13;
36    x ^= x >> 7;
37    x ^= x << 17;
38    *state = x;
39    x
40}
41
42// ── DpError ────────────────────────────────────────────────────────────────
43
44/// Errors produced by differential-privacy operations.
45#[derive(Debug, Error, Clone)]
46pub enum DpError {
47    /// Privacy budget is fully consumed.
48    #[error("privacy budget exhausted: remaining epsilon = {remaining:.6}")]
49    BudgetExhausted {
50        /// Remaining epsilon at the time of the error.
51        remaining: f64,
52    },
53
54    /// A parameter value was semantically invalid.
55    #[error("invalid parameters: {0}")]
56    InvalidParameters(String),
57
58    /// Sensitivity was zero or negative, making noise computation impossible.
59    #[error("sensitivity must be positive (got zero or negative)")]
60    ZeroSensitivity,
61
62    /// Epsilon was zero or negative, making the mechanism undefined.
63    #[error("epsilon must be strictly positive")]
64    InvalidEpsilon,
65}
66
67// ── PrivacyMechanism ───────────────────────────────────────────────────────
68
69/// The noise mechanism to apply when answering a differentially-private query.
70#[derive(Debug, Clone, PartialEq)]
71pub enum PrivacyMechanism {
72    /// Laplace mechanism: adds Laplace-distributed noise calibrated to
73    /// `sensitivity / epsilon`.
74    Laplace {
75        /// Global L1 sensitivity of the query function.
76        sensitivity: f64,
77        /// Privacy parameter ε > 0.
78        epsilon: f64,
79    },
80
81    /// Gaussian mechanism: adds Gaussian noise calibrated to achieve
82    /// (ε, δ)-differential privacy.
83    Gaussian {
84        /// Global L2 sensitivity of the query function.
85        sensitivity: f64,
86        /// Privacy parameter ε > 0.
87        epsilon: f64,
88        /// Privacy failure probability δ ∈ (0, 1).
89        delta: f64,
90    },
91
92    /// Randomized response mechanism for local differential privacy.
93    Randomized {
94        /// Privacy parameter ε > 0 (determines flip probability).
95        epsilon: f64,
96    },
97}
98
99impl PrivacyMechanism {
100    /// Return the epsilon associated with this mechanism.
101    pub fn epsilon(&self) -> f64 {
102        match self {
103            PrivacyMechanism::Laplace { epsilon, .. } => *epsilon,
104            PrivacyMechanism::Gaussian { epsilon, .. } => *epsilon,
105            PrivacyMechanism::Randomized { epsilon } => *epsilon,
106        }
107    }
108
109    /// Return the delta associated with this mechanism (0.0 for pure DP).
110    pub fn delta(&self) -> f64 {
111        match self {
112            PrivacyMechanism::Gaussian { delta, .. } => *delta,
113            _ => 0.0,
114        }
115    }
116
117    /// Return the sensitivity, if applicable (None for Randomized).
118    pub fn sensitivity(&self) -> Option<f64> {
119        match self {
120            PrivacyMechanism::Laplace { sensitivity, .. } => Some(*sensitivity),
121            PrivacyMechanism::Gaussian { sensitivity, .. } => Some(*sensitivity),
122            PrivacyMechanism::Randomized { .. } => None,
123        }
124    }
125
126    /// Validate mechanism parameters, returning an error on invalid values.
127    pub fn validate(&self) -> Result<(), DpError> {
128        let eps = self.epsilon();
129        if eps <= 0.0 {
130            return Err(DpError::InvalidEpsilon);
131        }
132        if let Some(s) = self.sensitivity() {
133            if s <= 0.0 {
134                return Err(DpError::ZeroSensitivity);
135            }
136        }
137        if let PrivacyMechanism::Gaussian { delta, .. } = self {
138            if *delta <= 0.0 || *delta >= 1.0 {
139                return Err(DpError::InvalidParameters(format!(
140                    "delta must be in (0,1), got {delta}"
141                )));
142            }
143        }
144        Ok(())
145    }
146}
147
148// ── NoiseScale ─────────────────────────────────────────────────────────────
149
150/// Computed noise scale for a given mechanism.
151///
152/// - Laplace:  `scale = sensitivity / epsilon`
153/// - Gaussian: `scale = sensitivity * sqrt(2 * ln(1.25 / delta)) / epsilon`
154/// - Randomized: `scale = 1.0 / (exp(epsilon) + 1)` (flip probability)
155#[derive(Debug, Clone)]
156pub struct NoiseScale {
157    /// The mechanism this scale was computed for.
158    pub mechanism: PrivacyMechanism,
159    /// The computed noise scale (standard deviation or rate parameter).
160    pub scale: f64,
161}
162
163// ── PrivacyParameters ──────────────────────────────────────────────────────
164
165/// Budget parameters bundling epsilon, delta, and sensitivity together.
166#[derive(Debug, Clone)]
167pub struct PrivacyParameters {
168    /// Privacy parameter ε.
169    pub epsilon: f64,
170    /// Privacy failure probability δ.
171    pub delta: f64,
172    /// Query sensitivity.
173    pub sensitivity: f64,
174}
175
176impl PrivacyParameters {
177    /// Construct and validate privacy parameters.
178    pub fn new(epsilon: f64, delta: f64, sensitivity: f64) -> Result<Self, DpError> {
179        if epsilon <= 0.0 {
180            return Err(DpError::InvalidEpsilon);
181        }
182        if sensitivity <= 0.0 {
183            return Err(DpError::ZeroSensitivity);
184        }
185        if !(0.0..1.0).contains(&delta) {
186            return Err(DpError::InvalidParameters(format!(
187                "delta must be in [0,1), got {delta}"
188            )));
189        }
190        Ok(Self {
191            epsilon,
192            delta,
193            sensitivity,
194        })
195    }
196}
197
198// ── DpQuery ────────────────────────────────────────────────────────────────
199
200/// A differentially-private query specification.
201#[derive(Debug, Clone)]
202pub struct DpQuery {
203    /// Unique identifier for this query.
204    pub query_id: String,
205    /// Global sensitivity of the query function.
206    pub sensitivity: f64,
207    /// Noise mechanism to apply.
208    pub mechanism: PrivacyMechanism,
209}
210
211// ── DpResult ───────────────────────────────────────────────────────────────
212
213/// The result of answering a differentially-private query.
214#[derive(Debug, Clone)]
215pub struct DpResult {
216    /// The query identifier this result corresponds to.
217    pub query_id: String,
218    /// The true (pre-noise) value.
219    pub true_value: f64,
220    /// The noisy (post-mechanism) value returned to the caller.
221    pub noisy_value: f64,
222    /// The signed noise that was added: `noisy_value - true_value`.
223    pub noise_added: f64,
224    /// The epsilon charged against the privacy budget for this query.
225    pub privacy_cost: f64,
226}
227
228// ── BudgetTracker ──────────────────────────────────────────────────────────
229
230/// Tracks consumed and remaining privacy budget.
231#[derive(Debug, Clone)]
232pub struct BudgetTracker {
233    /// Total epsilon allocated for all queries.
234    pub epsilon_budget: f64,
235    /// Epsilon consumed so far.
236    pub epsilon_used: f64,
237    /// Total delta allocated for all queries.
238    pub delta_budget: f64,
239    /// Delta consumed so far.
240    pub delta_used: f64,
241    /// Number of queries answered successfully.
242    pub queries_answered: u64,
243}
244
245impl BudgetTracker {
246    /// Construct a new tracker with given budgets and zero consumption.
247    pub fn new(epsilon_budget: f64, delta_budget: f64) -> Self {
248        Self {
249            epsilon_budget,
250            epsilon_used: 0.0,
251            delta_budget,
252            delta_used: 0.0,
253            queries_answered: 0,
254        }
255    }
256
257    /// Remaining epsilon = budget − used.
258    pub fn remaining_epsilon(&self) -> f64 {
259        (self.epsilon_budget - self.epsilon_used).max(0.0)
260    }
261
262    /// Remaining delta = budget − used.
263    pub fn remaining_delta(&self) -> f64 {
264        (self.delta_budget - self.delta_used).max(0.0)
265    }
266
267    /// Returns true when epsilon_used ≥ epsilon_budget.
268    pub fn is_exhausted(&self) -> bool {
269        self.epsilon_used >= self.epsilon_budget
270    }
271
272    /// Charge epsilon and delta to the budget. Returns an error if the budget
273    /// would be exceeded.
274    pub fn charge(&mut self, epsilon_cost: f64, delta_cost: f64) -> Result<(), DpError> {
275        if self.is_exhausted() || self.epsilon_used + epsilon_cost > self.epsilon_budget {
276            return Err(DpError::BudgetExhausted {
277                remaining: self.remaining_epsilon(),
278            });
279        }
280        self.epsilon_used += epsilon_cost;
281        self.delta_used += delta_cost;
282        self.queries_answered += 1;
283        Ok(())
284    }
285}
286
287// ── DifferentialPrivacyEngine ──────────────────────────────────────────────
288
289/// Production-grade differential-privacy engine.
290///
291/// Manages a privacy budget, generates calibrated noise, and records an
292/// auditable history of answered queries.
293pub struct DifferentialPrivacyEngine {
294    /// Live budget tracker.
295    pub budget: BudgetTracker,
296    /// Ring-buffer of answered query results (bounded by `max_history`).
297    answered: VecDeque<DpResult>,
298    /// Maximum number of results retained in history.
299    max_history: usize,
300    /// xorshift64 PRNG state.
301    rng_state: u64,
302}
303
304impl DifferentialPrivacyEngine {
305    /// Construct a new engine with the given budget parameters.
306    ///
307    /// The PRNG is seeded with `0xDEADBEEF42`.
308    pub fn new(epsilon_budget: f64, delta_budget: f64, max_history: usize) -> Self {
309        Self {
310            budget: BudgetTracker::new(epsilon_budget, delta_budget),
311            answered: VecDeque::new(),
312            max_history,
313            rng_state: 0x00DE_ADBE_EF42_u64,
314        }
315    }
316
317    // ── Noise-scale computation ────────────────────────────────────────────
318
319    /// Compute the noise scale for a given mechanism.
320    ///
321    /// - Laplace:  `scale = sensitivity / epsilon`
322    /// - Gaussian: `scale = sensitivity * sqrt(2 * ln(1.25 / delta)) / epsilon`
323    /// - Randomized: `scale = 1 / (exp(epsilon) + 1)` (flip probability)
324    pub fn compute_noise_scale(mechanism: &PrivacyMechanism) -> NoiseScale {
325        let scale = match mechanism {
326            PrivacyMechanism::Laplace {
327                sensitivity,
328                epsilon,
329            } => sensitivity / epsilon,
330
331            PrivacyMechanism::Gaussian {
332                sensitivity,
333                epsilon,
334                delta,
335            } => {
336                // Calibrated to satisfy (epsilon, delta)-DP via the analytic Gaussian mechanism.
337                let inner = 2.0_f64 * (1.25_f64 / delta).ln();
338                sensitivity * inner.sqrt() / epsilon
339            }
340
341            PrivacyMechanism::Randomized { epsilon } => 1.0 / (epsilon.exp() + 1.0),
342        };
343        NoiseScale {
344            mechanism: mechanism.clone(),
345            scale,
346        }
347    }
348
349    // ── Noise sampling ─────────────────────────────────────────────────────
350
351    /// Draw a uniform sample from (0, 1) using xorshift64.
352    fn uniform_sample(&mut self) -> f64 {
353        let raw = xorshift64(&mut self.rng_state);
354        raw as f64 / u64::MAX as f64
355    }
356
357    /// Sample from Laplace(0, scale) using the inverse-CDF method.
358    ///
359    /// Formula: `-scale * sign(u - 0.5) * ln(1 - 2 * |u - 0.5|)`.
360    /// If the argument to `ln` is ≤ 0, uses `1e-10` as a floor.
361    pub fn sample_laplace(&mut self, scale: f64) -> f64 {
362        let u = self.uniform_sample();
363        let centered = u - 0.5;
364        let sign = if centered >= 0.0 { 1.0_f64 } else { -1.0_f64 };
365        let arg = (1.0 - 2.0 * centered.abs()).max(1e-10);
366        -scale * sign * arg.ln()
367    }
368
369    /// Sample from Gaussian(0, scale) using the Box-Muller transform.
370    ///
371    /// Draws two uniform samples u1, u2 ∈ (0,1), then:
372    /// `z = sqrt(-2 * ln(u1)) * cos(2π * u2)`.
373    /// If `u1 ≤ 0`, uses `1e-10` as a floor.
374    pub fn sample_gaussian(&mut self, scale: f64) -> f64 {
375        let u1 = self.uniform_sample().max(1e-10);
376        let u2 = self.uniform_sample();
377        let z = (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos();
378        z * scale
379    }
380
381    /// Sample noise according to the mechanism (Laplace, Gaussian, or
382    /// Randomized).
383    fn sample_noise(&mut self, mechanism: &PrivacyMechanism, true_value: f64) -> f64 {
384        let ns = Self::compute_noise_scale(mechanism);
385        match mechanism {
386            PrivacyMechanism::Laplace { .. } => self.sample_laplace(ns.scale),
387            PrivacyMechanism::Gaussian { .. } => self.sample_gaussian(ns.scale),
388            PrivacyMechanism::Randomized { epsilon } => {
389                // Randomized response: flip the binary encoding of the value
390                // with probability p = 1/(exp(ε)+1).
391                let flip_prob = ns.scale; // = 1 / (exp(ε) + 1)
392                let u = self.uniform_sample();
393                if u < flip_prob {
394                    // Flip: add a perturbation of magnitude 1.0 in a random direction.
395                    let sign = if self.uniform_sample() < 0.5 {
396                        1.0_f64
397                    } else {
398                        -1.0_f64
399                    };
400                    let _ = epsilon; // used via scale
401                    sign * 1.0 - true_value + true_value // = sign * 1.0 (placeholder)
402                } else {
403                    0.0
404                }
405            }
406        }
407    }
408
409    // ── Query application ──────────────────────────────────────────────────
410
411    /// Answer a single differentially-private query.
412    ///
413    /// 1. Validates mechanism parameters.
414    /// 2. Checks that the budget is not exhausted.
415    /// 3. Generates calibrated noise.
416    /// 4. Charges epsilon (and delta for Gaussian) to the budget.
417    /// 5. Records the result in history and returns it.
418    pub fn apply_mechanism(
419        &mut self,
420        query: &DpQuery,
421        true_value: f64,
422    ) -> Result<DpResult, DpError> {
423        // Validate mechanism parameters up-front.
424        query.mechanism.validate()?;
425
426        // Guard against exhausted budget before allocating noise.
427        if self.budget.is_exhausted() {
428            return Err(DpError::BudgetExhausted {
429                remaining: self.budget.remaining_epsilon(),
430            });
431        }
432
433        // Compute noise.
434        let noise = self.sample_noise(&query.mechanism, true_value);
435        let noisy_value = true_value + noise;
436
437        // Determine privacy cost for this query.
438        let epsilon_cost = query.mechanism.epsilon();
439        let delta_cost = query.mechanism.delta();
440
441        // Charge budget (may fail if insufficient).
442        self.budget.charge(epsilon_cost, delta_cost)?;
443
444        let result = DpResult {
445            query_id: query.query_id.clone(),
446            true_value,
447            noisy_value,
448            noise_added: noise,
449            privacy_cost: epsilon_cost,
450        };
451
452        // Maintain bounded history.
453        if self.answered.len() >= self.max_history && self.max_history > 0 {
454            self.answered.pop_front();
455        }
456        if self.max_history > 0 {
457            self.answered.push_back(result.clone());
458        }
459
460        Ok(result)
461    }
462
463    /// Answer a batch of queries, applying each in sequence.
464    ///
465    /// Each result is `Ok` if the query succeeded, or `Err` if the budget
466    /// was exhausted or parameters were invalid. Later queries in the batch
467    /// see the already-reduced budget from earlier queries.
468    pub fn apply_batch(&mut self, queries: &[(DpQuery, f64)]) -> Vec<Result<DpResult, DpError>> {
469        queries
470            .iter()
471            .map(|(q, v)| self.apply_mechanism(q, *v))
472            .collect()
473    }
474
475    // ── Composition theorems ───────────────────────────────────────────────
476
477    /// Sequential composition: total epsilon = sum of per-query privacy costs.
478    pub fn compose_sequential(results: &[DpResult]) -> f64 {
479        results.iter().map(|r| r.privacy_cost).sum()
480    }
481
482    /// Advanced composition theorem (Dwork et al. 2010).
483    ///
484    /// For k independent (ε, 0)-DP mechanisms:
485    ///
486    /// ```text
487    /// ε_total = sqrt(2k ln(1/δ)) * ε + k * ε * (exp(ε) - 1)
488    /// ```
489    ///
490    /// where ε is the maximum per-query cost and k is the number of queries.
491    /// Returns the sequential bound when `results` is empty.
492    pub fn compose_advanced(results: &[DpResult], delta: f64) -> f64 {
493        if results.is_empty() {
494            return 0.0;
495        }
496        let k = results.len() as f64;
497        let epsilon_per_query = results
498            .iter()
499            .map(|r| r.privacy_cost)
500            .fold(f64::NEG_INFINITY, f64::max);
501
502        let eps = epsilon_per_query;
503        let term1 = (2.0 * k * (1.0 / delta).ln()).sqrt() * eps;
504        let term2 = k * eps * (eps.exp() - 1.0);
505        term1 + term2
506    }
507
508    // ── Sensitivity clipping ───────────────────────────────────────────────
509
510    /// Clip each value to the range `[-sensitivity, sensitivity]`.
511    ///
512    /// This enforces global sensitivity bounds before computing statistics.
513    pub fn sensitivity_clip(values: &[f64], sensitivity: f64) -> Vec<f64> {
514        values
515            .iter()
516            .map(|&v| v.clamp(-sensitivity, sensitivity))
517            .collect()
518    }
519
520    // ── Budget and history accessors ───────────────────────────────────────
521
522    /// Return a clone of the current budget tracker.
523    pub fn budget_stats(&self) -> BudgetTracker {
524        self.budget.clone()
525    }
526
527    /// Return a reference to the bounded query-result history.
528    pub fn history(&self) -> &VecDeque<DpResult> {
529        &self.answered
530    }
531
532    /// Return a mutable reference to the budget tracker (for testing / integration).
533    pub fn budget_mut(&mut self) -> &mut BudgetTracker {
534        &mut self.budget
535    }
536
537    /// Reset the PRNG to a known seed for reproducible testing.
538    pub fn reseed(&mut self, seed: u64) {
539        // Ensure the seed is non-zero (xorshift64 with state=0 always produces 0).
540        self.rng_state = if seed == 0 { 1 } else { seed };
541    }
542
543    /// Clear the query history.
544    pub fn clear_history(&mut self) {
545        self.answered.clear();
546    }
547}
548
549// ── Tests ──────────────────────────────────────────────────────────────────
550
551#[cfg(test)]
552mod tests {
553    use crate::differential_privacy::{
554        xorshift64, BudgetTracker, DifferentialPrivacyEngine, DpError, DpQuery, DpResult,
555        NoiseScale, PrivacyMechanism, PrivacyParameters,
556    };
557
558    // ── xorshift64 ─────────────────────────────────────────────────────────
559
560    #[test]
561    fn test_xorshift64_non_zero() {
562        let mut state = 0x00DE_ADBE_EF42_u64;
563        let v = xorshift64(&mut state);
564        assert_ne!(v, 0);
565        assert_ne!(state, 0x00DE_ADBE_EF42_u64);
566    }
567
568    #[test]
569    fn test_xorshift64_deterministic() {
570        let mut s1 = 12345u64;
571        let mut s2 = 12345u64;
572        for _ in 0..100 {
573            assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
574        }
575    }
576
577    #[test]
578    fn test_xorshift64_different_outputs() {
579        let mut state = 1u64;
580        let a = xorshift64(&mut state);
581        let b = xorshift64(&mut state);
582        assert_ne!(a, b);
583    }
584
585    // ── PrivacyMechanism ───────────────────────────────────────────────────
586
587    #[test]
588    fn test_laplace_mechanism_epsilon() {
589        let m = PrivacyMechanism::Laplace {
590            sensitivity: 1.0,
591            epsilon: 0.5,
592        };
593        assert_eq!(m.epsilon(), 0.5);
594        assert_eq!(m.delta(), 0.0);
595        assert_eq!(m.sensitivity(), Some(1.0));
596    }
597
598    #[test]
599    fn test_gaussian_mechanism_fields() {
600        let m = PrivacyMechanism::Gaussian {
601            sensitivity: 2.0,
602            epsilon: 1.0,
603            delta: 1e-5,
604        };
605        assert_eq!(m.epsilon(), 1.0);
606        assert_eq!(m.delta(), 1e-5);
607        assert_eq!(m.sensitivity(), Some(2.0));
608    }
609
610    #[test]
611    fn test_randomized_mechanism_fields() {
612        let m = PrivacyMechanism::Randomized { epsilon: 0.5 };
613        assert_eq!(m.epsilon(), 0.5);
614        assert_eq!(m.delta(), 0.0);
615        assert!(m.sensitivity().is_none());
616    }
617
618    #[test]
619    fn test_mechanism_validate_ok() {
620        let m = PrivacyMechanism::Laplace {
621            sensitivity: 1.0,
622            epsilon: 1.0,
623        };
624        assert!(m.validate().is_ok());
625    }
626
627    #[test]
628    fn test_mechanism_validate_invalid_epsilon() {
629        let m = PrivacyMechanism::Laplace {
630            sensitivity: 1.0,
631            epsilon: 0.0,
632        };
633        assert!(matches!(m.validate(), Err(DpError::InvalidEpsilon)));
634    }
635
636    #[test]
637    fn test_mechanism_validate_zero_sensitivity() {
638        let m = PrivacyMechanism::Laplace {
639            sensitivity: 0.0,
640            epsilon: 1.0,
641        };
642        assert!(matches!(m.validate(), Err(DpError::ZeroSensitivity)));
643    }
644
645    #[test]
646    fn test_mechanism_validate_gaussian_invalid_delta() {
647        let m = PrivacyMechanism::Gaussian {
648            sensitivity: 1.0,
649            epsilon: 1.0,
650            delta: 0.0,
651        };
652        assert!(matches!(m.validate(), Err(DpError::InvalidParameters(_))));
653    }
654
655    // ── NoiseScale ─────────────────────────────────────────────────────────
656
657    #[test]
658    fn test_laplace_noise_scale() {
659        let m = PrivacyMechanism::Laplace {
660            sensitivity: 1.0,
661            epsilon: 2.0,
662        };
663        let ns = DifferentialPrivacyEngine::compute_noise_scale(&m);
664        // scale = 1.0 / 2.0 = 0.5
665        assert!((ns.scale - 0.5).abs() < 1e-12);
666    }
667
668    #[test]
669    fn test_gaussian_noise_scale() {
670        let delta = 1e-5;
671        let m = PrivacyMechanism::Gaussian {
672            sensitivity: 1.0,
673            epsilon: 1.0,
674            delta,
675        };
676        let ns = DifferentialPrivacyEngine::compute_noise_scale(&m);
677        let expected = (2.0 * (1.25 / delta).ln()).sqrt();
678        assert!((ns.scale - expected).abs() < 1e-10);
679    }
680
681    #[test]
682    fn test_gaussian_noise_scale_scales_with_sensitivity() {
683        let m1 = PrivacyMechanism::Gaussian {
684            sensitivity: 1.0,
685            epsilon: 1.0,
686            delta: 1e-5,
687        };
688        let m2 = PrivacyMechanism::Gaussian {
689            sensitivity: 2.0,
690            epsilon: 1.0,
691            delta: 1e-5,
692        };
693        let ns1 = DifferentialPrivacyEngine::compute_noise_scale(&m1);
694        let ns2 = DifferentialPrivacyEngine::compute_noise_scale(&m2);
695        assert!((ns2.scale - 2.0 * ns1.scale).abs() < 1e-10);
696    }
697
698    #[test]
699    fn test_randomized_noise_scale() {
700        let eps = 1.0_f64;
701        let m = PrivacyMechanism::Randomized { epsilon: eps };
702        let ns = DifferentialPrivacyEngine::compute_noise_scale(&m);
703        let expected = 1.0 / (eps.exp() + 1.0);
704        assert!((ns.scale - expected).abs() < 1e-12);
705    }
706
707    // ── PrivacyParameters ──────────────────────────────────────────────────
708
709    #[test]
710    fn test_privacy_parameters_valid() {
711        let p = PrivacyParameters::new(1.0, 1e-5, 1.0);
712        assert!(p.is_ok());
713        let p = p.expect("test: should succeed");
714        assert_eq!(p.epsilon, 1.0);
715        assert_eq!(p.delta, 1e-5);
716        assert_eq!(p.sensitivity, 1.0);
717    }
718
719    #[test]
720    fn test_privacy_parameters_invalid_epsilon() {
721        assert!(matches!(
722            PrivacyParameters::new(0.0, 1e-5, 1.0),
723            Err(DpError::InvalidEpsilon)
724        ));
725    }
726
727    #[test]
728    fn test_privacy_parameters_invalid_sensitivity() {
729        assert!(matches!(
730            PrivacyParameters::new(1.0, 1e-5, 0.0),
731            Err(DpError::ZeroSensitivity)
732        ));
733    }
734
735    #[test]
736    fn test_privacy_parameters_invalid_delta() {
737        assert!(matches!(
738            PrivacyParameters::new(1.0, -0.1, 1.0),
739            Err(DpError::InvalidParameters(_))
740        ));
741    }
742
743    // ── BudgetTracker ──────────────────────────────────────────────────────
744
745    #[test]
746    fn test_budget_tracker_initial_state() {
747        let bt = BudgetTracker::new(10.0, 1e-5);
748        assert_eq!(bt.epsilon_budget, 10.0);
749        assert_eq!(bt.epsilon_used, 0.0);
750        assert!(!bt.is_exhausted());
751        assert!((bt.remaining_epsilon() - 10.0).abs() < 1e-12);
752    }
753
754    #[test]
755    fn test_budget_tracker_charge_success() {
756        let mut bt = BudgetTracker::new(5.0, 1e-4);
757        bt.charge(2.0, 1e-5).expect("test: should succeed");
758        assert!((bt.remaining_epsilon() - 3.0).abs() < 1e-12);
759        assert_eq!(bt.queries_answered, 1);
760        assert!(!bt.is_exhausted());
761    }
762
763    #[test]
764    fn test_budget_tracker_exhaustion() {
765        let mut bt = BudgetTracker::new(1.0, 0.0);
766        bt.charge(1.0, 0.0).expect("test: should succeed");
767        assert!(bt.is_exhausted());
768        // Trying again should fail.
769        let err = bt.charge(0.5, 0.0);
770        assert!(matches!(err, Err(DpError::BudgetExhausted { .. })));
771    }
772
773    #[test]
774    fn test_budget_tracker_remaining_floored_at_zero() {
775        let mut bt = BudgetTracker::new(1.0, 0.0);
776        bt.charge(1.0, 0.0).expect("test: should succeed");
777        assert_eq!(bt.remaining_epsilon(), 0.0);
778    }
779
780    // ── DifferentialPrivacyEngine ──────────────────────────────────────────
781
782    #[test]
783    fn test_engine_construction() {
784        let engine = DifferentialPrivacyEngine::new(10.0, 1e-5, 100);
785        assert_eq!(engine.budget.epsilon_budget, 10.0);
786        assert_eq!(engine.history().len(), 0);
787    }
788
789    #[test]
790    fn test_engine_laplace_query() {
791        let mut engine = DifferentialPrivacyEngine::new(10.0, 0.0, 100);
792        let query = DpQuery {
793            query_id: "test_laplace".to_string(),
794            sensitivity: 1.0,
795            mechanism: PrivacyMechanism::Laplace {
796                sensitivity: 1.0,
797                epsilon: 1.0,
798            },
799        };
800        let result = engine
801            .apply_mechanism(&query, 100.0)
802            .expect("test: should succeed");
803        assert_eq!(result.query_id, "test_laplace");
804        assert!(result.noisy_value.is_finite());
805        assert!((result.noise_added - (result.noisy_value - result.true_value)).abs() < 1e-10);
806        assert_eq!(result.privacy_cost, 1.0);
807    }
808
809    #[test]
810    fn test_engine_gaussian_query() {
811        let mut engine = DifferentialPrivacyEngine::new(10.0, 1.0, 100);
812        let query = DpQuery {
813            query_id: "test_gaussian".to_string(),
814            sensitivity: 1.0,
815            mechanism: PrivacyMechanism::Gaussian {
816                sensitivity: 1.0,
817                epsilon: 1.0,
818                delta: 1e-5,
819            },
820        };
821        let result = engine
822            .apply_mechanism(&query, 50.0)
823            .expect("test: should succeed");
824        assert_eq!(result.query_id, "test_gaussian");
825        assert!(result.noisy_value.is_finite());
826    }
827
828    #[test]
829    fn test_engine_budget_deduction() {
830        let mut engine = DifferentialPrivacyEngine::new(3.0, 0.0, 100);
831        let query = DpQuery {
832            query_id: "q".to_string(),
833            sensitivity: 1.0,
834            mechanism: PrivacyMechanism::Laplace {
835                sensitivity: 1.0,
836                epsilon: 1.0,
837            },
838        };
839        engine
840            .apply_mechanism(&query, 1.0)
841            .expect("test: should succeed");
842        engine
843            .apply_mechanism(&query, 2.0)
844            .expect("test: should succeed");
845        engine
846            .apply_mechanism(&query, 3.0)
847            .expect("test: should succeed");
848        assert!(engine.budget.is_exhausted());
849        let err = engine.apply_mechanism(&query, 4.0);
850        assert!(matches!(err, Err(DpError::BudgetExhausted { .. })));
851    }
852
853    #[test]
854    fn test_engine_history_bounded() {
855        let mut engine = DifferentialPrivacyEngine::new(1000.0, 0.0, 3);
856        let query = DpQuery {
857            query_id: "q".to_string(),
858            sensitivity: 1.0,
859            mechanism: PrivacyMechanism::Laplace {
860                sensitivity: 1.0,
861                epsilon: 0.1,
862            },
863        };
864        for _ in 0..10 {
865            engine
866                .apply_mechanism(&query, 0.0)
867                .expect("test: should succeed");
868        }
869        assert_eq!(engine.history().len(), 3);
870    }
871
872    #[test]
873    fn test_engine_invalid_mechanism_rejected() {
874        let mut engine = DifferentialPrivacyEngine::new(10.0, 0.0, 100);
875        let query = DpQuery {
876            query_id: "bad".to_string(),
877            sensitivity: 0.0,
878            mechanism: PrivacyMechanism::Laplace {
879                sensitivity: -1.0,
880                epsilon: 1.0,
881            },
882        };
883        let err = engine.apply_mechanism(&query, 0.0);
884        assert!(err.is_err());
885    }
886
887    #[test]
888    fn test_engine_batch_apply() {
889        let mut engine = DifferentialPrivacyEngine::new(100.0, 0.0, 100);
890        let queries: Vec<(DpQuery, f64)> = (0..5)
891            .map(|i| {
892                (
893                    DpQuery {
894                        query_id: format!("q{i}"),
895                        sensitivity: 1.0,
896                        mechanism: PrivacyMechanism::Laplace {
897                            sensitivity: 1.0,
898                            epsilon: 1.0,
899                        },
900                    },
901                    i as f64,
902                )
903            })
904            .collect();
905        let results = engine.apply_batch(&queries);
906        assert_eq!(results.len(), 5);
907        for r in &results {
908            assert!(r.is_ok());
909        }
910    }
911
912    #[test]
913    fn test_engine_batch_stops_on_budget_exhaustion() {
914        // Budget for exactly 2 queries.
915        let mut engine = DifferentialPrivacyEngine::new(2.0, 0.0, 100);
916        let queries: Vec<(DpQuery, f64)> = (0..5)
917            .map(|i| {
918                (
919                    DpQuery {
920                        query_id: format!("q{i}"),
921                        sensitivity: 1.0,
922                        mechanism: PrivacyMechanism::Laplace {
923                            sensitivity: 1.0,
924                            epsilon: 1.0,
925                        },
926                    },
927                    i as f64,
928                )
929            })
930            .collect();
931        let results = engine.apply_batch(&queries);
932        let ok_count = results.iter().filter(|r| r.is_ok()).count();
933        let err_count = results.iter().filter(|r| r.is_err()).count();
934        assert_eq!(ok_count, 2);
935        assert_eq!(err_count, 3);
936    }
937
938    // ── Composition theorems ───────────────────────────────────────────────
939
940    #[test]
941    fn test_compose_sequential_empty() {
942        assert_eq!(DifferentialPrivacyEngine::compose_sequential(&[]), 0.0);
943    }
944
945    #[test]
946    fn test_compose_sequential_sums_costs() {
947        let results = vec![
948            make_result("a", 1.0),
949            make_result("b", 0.5),
950            make_result("c", 2.0),
951        ];
952        let total = DifferentialPrivacyEngine::compose_sequential(&results);
953        assert!((total - 3.5).abs() < 1e-12);
954    }
955
956    #[test]
957    fn test_compose_advanced_empty() {
958        assert_eq!(DifferentialPrivacyEngine::compose_advanced(&[], 1e-5), 0.0);
959    }
960
961    #[test]
962    fn test_compose_advanced_single_query() {
963        let results = vec![make_result("a", 1.0)];
964        let eps_adv = DifferentialPrivacyEngine::compose_advanced(&results, 1e-5);
965        // For k=1: sqrt(2 * ln(1/delta)) * eps + eps * (exp(eps) - 1)
966        let delta = 1e-5_f64;
967        let eps = 1.0_f64;
968        let expected = (2.0 * (1.0 / delta).ln()).sqrt() * eps + eps * (eps.exp() - 1.0);
969        assert!((eps_adv - expected).abs() < 1e-10);
970    }
971
972    #[test]
973    fn test_compose_advanced_larger_than_sequential_for_many_queries() {
974        // Advanced composition can exceed sequential for small k but diverges
975        // for large k — here we just check it is positive and finite.
976        let results: Vec<DpResult> = (0..20)
977            .map(|i| make_result(&format!("q{i}"), 0.1))
978            .collect();
979        let eps_adv = DifferentialPrivacyEngine::compose_advanced(&results, 1e-5);
980        assert!(eps_adv > 0.0);
981        assert!(eps_adv.is_finite());
982    }
983
984    // ── Sensitivity clipping ───────────────────────────────────────────────
985
986    #[test]
987    fn test_sensitivity_clip_within_bounds() {
988        let values = vec![0.5, -0.3, 0.0];
989        let clipped = DifferentialPrivacyEngine::sensitivity_clip(&values, 1.0);
990        assert_eq!(clipped, values);
991    }
992
993    #[test]
994    fn test_sensitivity_clip_above_bound() {
995        let values = vec![5.0, -5.0, 2.0];
996        let clipped = DifferentialPrivacyEngine::sensitivity_clip(&values, 1.0);
997        assert_eq!(clipped, vec![1.0, -1.0, 1.0]);
998    }
999
1000    #[test]
1001    fn test_sensitivity_clip_empty() {
1002        let clipped = DifferentialPrivacyEngine::sensitivity_clip(&[], 1.0);
1003        assert!(clipped.is_empty());
1004    }
1005
1006    #[test]
1007    fn test_sensitivity_clip_preserves_sign() {
1008        let values = vec![-10.0, 10.0];
1009        let clipped = DifferentialPrivacyEngine::sensitivity_clip(&values, 3.0);
1010        assert_eq!(clipped, vec![-3.0, 3.0]);
1011    }
1012
1013    // ── Noise distribution properties ─────────────────────────────────────
1014
1015    #[test]
1016    fn test_laplace_noise_finite() {
1017        let mut engine = DifferentialPrivacyEngine::new(1000.0, 0.0, 1000);
1018        for _ in 0..1000 {
1019            let noise = engine.sample_laplace(1.0);
1020            assert!(noise.is_finite(), "Laplace noise must be finite");
1021        }
1022    }
1023
1024    #[test]
1025    fn test_gaussian_noise_finite() {
1026        let mut engine = DifferentialPrivacyEngine::new(1000.0, 1000.0, 1000);
1027        for _ in 0..1000 {
1028            let noise = engine.sample_gaussian(1.0);
1029            assert!(noise.is_finite(), "Gaussian noise must be finite");
1030        }
1031    }
1032
1033    #[test]
1034    fn test_laplace_noise_mean_near_zero() {
1035        // Empirical mean of 10 000 samples should be within ±0.15 of 0.
1036        let mut engine = DifferentialPrivacyEngine::new(f64::MAX, 0.0, 0);
1037        let n = 10_000usize;
1038        let mean: f64 = (0..n).map(|_| engine.sample_laplace(1.0)).sum::<f64>() / n as f64;
1039        assert!(
1040            mean.abs() < 0.15,
1041            "Empirical mean of Laplace samples too large: {mean}"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_gaussian_noise_mean_near_zero() {
1047        let mut engine = DifferentialPrivacyEngine::new(f64::MAX, 0.0, 0);
1048        let n = 10_000usize;
1049        let mean: f64 = (0..n).map(|_| engine.sample_gaussian(1.0)).sum::<f64>() / n as f64;
1050        assert!(
1051            mean.abs() < 0.15,
1052            "Empirical mean of Gaussian samples too large: {mean}"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_laplace_noise_scale_affects_variance() {
1058        let mut e1 = DifferentialPrivacyEngine::new(f64::MAX, 0.0, 0);
1059        e1.reseed(0xCAFE_BABE);
1060        let mut e2 = DifferentialPrivacyEngine::new(f64::MAX, 0.0, 0);
1061        e2.reseed(0xCAFE_BABE);
1062        let n = 1000usize;
1063        let var1: f64 = (0..n).map(|_| e1.sample_laplace(1.0).powi(2)).sum::<f64>() / n as f64;
1064        let var2: f64 = (0..n).map(|_| e2.sample_laplace(2.0).powi(2)).sum::<f64>() / n as f64;
1065        // Var[Laplace(0,b)] = 2b² — so var2 should be ~4x var1.
1066        assert!(var2 > var1 * 2.0, "Larger scale should increase variance");
1067    }
1068
1069    // ── Reseed and clear_history ───────────────────────────────────────────
1070
1071    #[test]
1072    fn test_reseed_reproducibility() {
1073        let mut engine = DifferentialPrivacyEngine::new(f64::MAX, 0.0, 0);
1074        engine.reseed(42);
1075        let a = engine.sample_laplace(1.0);
1076        engine.reseed(42);
1077        let b = engine.sample_laplace(1.0);
1078        assert_eq!(a, b);
1079    }
1080
1081    #[test]
1082    fn test_clear_history() {
1083        let mut engine = DifferentialPrivacyEngine::new(100.0, 0.0, 100);
1084        let query = DpQuery {
1085            query_id: "q".to_string(),
1086            sensitivity: 1.0,
1087            mechanism: PrivacyMechanism::Laplace {
1088                sensitivity: 1.0,
1089                epsilon: 1.0,
1090            },
1091        };
1092        engine
1093            .apply_mechanism(&query, 0.0)
1094            .expect("test: should succeed");
1095        assert_eq!(engine.history().len(), 1);
1096        engine.clear_history();
1097        assert_eq!(engine.history().len(), 0);
1098    }
1099
1100    #[test]
1101    fn test_budget_stats_clones_current_state() {
1102        let mut engine = DifferentialPrivacyEngine::new(10.0, 0.0, 100);
1103        let query = DpQuery {
1104            query_id: "q".to_string(),
1105            sensitivity: 1.0,
1106            mechanism: PrivacyMechanism::Laplace {
1107                sensitivity: 1.0,
1108                epsilon: 2.0,
1109            },
1110        };
1111        engine
1112            .apply_mechanism(&query, 0.0)
1113            .expect("test: should succeed");
1114        let stats = engine.budget_stats();
1115        assert!((stats.epsilon_used - 2.0).abs() < 1e-12);
1116        assert!((stats.remaining_epsilon() - 8.0).abs() < 1e-12);
1117    }
1118
1119    #[test]
1120    fn test_noise_scale_struct_carries_mechanism() {
1121        let m = PrivacyMechanism::Laplace {
1122            sensitivity: 3.0,
1123            epsilon: 1.5,
1124        };
1125        let ns: NoiseScale = DifferentialPrivacyEngine::compute_noise_scale(&m);
1126        assert_eq!(ns.mechanism, m);
1127        assert!((ns.scale - 2.0).abs() < 1e-12);
1128    }
1129
1130    // ── Helper ─────────────────────────────────────────────────────────────
1131
1132    fn make_result(id: &str, cost: f64) -> DpResult {
1133        DpResult {
1134            query_id: id.to_string(),
1135            true_value: 0.0,
1136            noisy_value: 0.0,
1137            noise_added: 0.0,
1138            privacy_cost: cost,
1139        }
1140    }
1141}