Skip to main content

rvf_federation/
diff_privacy.rs

1//! Differential privacy primitives for federated learning.
2//!
3//! Provides calibrated noise injection, gradient clipping, and a Renyi
4//! Differential Privacy (RDP) accountant for tracking cumulative privacy loss.
5
6use rand::rngs::StdRng;
7use rand::{Rng, SeedableRng};
8use rand_distr::{Distribution, Normal};
9
10use crate::error::FederationError;
11use crate::types::{DiffPrivacyProof, NoiseMechanism};
12
13/// Differential privacy engine for adding calibrated noise.
14pub struct DiffPrivacyEngine {
15    /// Target epsilon (privacy loss bound).
16    epsilon: f64,
17    /// Target delta (probability of exceeding epsilon).
18    delta: f64,
19    /// L2 sensitivity bound.
20    sensitivity: f64,
21    /// Gradient clipping norm.
22    clipping_norm: f64,
23    /// Noise mechanism.
24    mechanism: NoiseMechanism,
25    /// Random number generator.
26    rng: StdRng,
27}
28
29impl DiffPrivacyEngine {
30    /// Create a new DP engine with Gaussian mechanism.
31    ///
32    /// Default: epsilon=1.0, delta=1e-5 (strong privacy).
33    pub fn gaussian(
34        epsilon: f64,
35        delta: f64,
36        sensitivity: f64,
37        clipping_norm: f64,
38    ) -> Result<Self, FederationError> {
39        if epsilon <= 0.0 {
40            return Err(FederationError::InvalidEpsilon(epsilon));
41        }
42        if delta <= 0.0 || delta >= 1.0 {
43            return Err(FederationError::InvalidDelta(delta));
44        }
45        Ok(Self {
46            epsilon,
47            delta,
48            sensitivity,
49            clipping_norm,
50            mechanism: NoiseMechanism::Gaussian,
51            rng: StdRng::from_rng(rand::thread_rng()).unwrap(),
52        })
53    }
54
55    /// Create a new DP engine with Laplace mechanism.
56    pub fn laplace(
57        epsilon: f64,
58        sensitivity: f64,
59        clipping_norm: f64,
60    ) -> Result<Self, FederationError> {
61        if epsilon <= 0.0 {
62            return Err(FederationError::InvalidEpsilon(epsilon));
63        }
64        Ok(Self {
65            epsilon,
66            delta: 0.0,
67            sensitivity,
68            clipping_norm,
69            mechanism: NoiseMechanism::Laplace,
70            rng: StdRng::from_rng(rand::thread_rng()).unwrap(),
71        })
72    }
73
74    /// Create with a deterministic seed (for testing).
75    pub fn with_seed(mut self, seed: u64) -> Self {
76        self.rng = StdRng::seed_from_u64(seed);
77        self
78    }
79
80    /// Compute the Gaussian noise standard deviation (sigma).
81    fn gaussian_sigma(&self) -> f64 {
82        self.sensitivity * (2.0_f64 * (1.25_f64 / self.delta).ln()).sqrt() / self.epsilon
83    }
84
85    /// Compute the Laplace noise scale (b).
86    fn laplace_scale(&self) -> f64 {
87        self.sensitivity / self.epsilon
88    }
89
90    /// Clip a gradient vector to the configured L2 norm bound.
91    pub fn clip_gradients(&self, gradients: &mut [f64]) {
92        let norm: f64 = gradients.iter().map(|x| x * x).sum::<f64>().sqrt();
93        if norm > self.clipping_norm {
94            let scale = self.clipping_norm / norm;
95            for g in gradients.iter_mut() {
96                *g *= scale;
97            }
98        }
99    }
100
101    /// Add calibrated noise to a vector of parameters.
102    ///
103    /// Clips gradients first, then adds noise per the configured mechanism.
104    pub fn add_noise(&mut self, params: &mut [f64]) -> DiffPrivacyProof {
105        self.clip_gradients(params);
106
107        match self.mechanism {
108            NoiseMechanism::Gaussian => {
109                let sigma = self.gaussian_sigma();
110                let normal = Normal::new(0.0, sigma).unwrap();
111                for p in params.iter_mut() {
112                    *p += normal.sample(&mut self.rng);
113                }
114                DiffPrivacyProof {
115                    epsilon: self.epsilon,
116                    delta: self.delta,
117                    mechanism: NoiseMechanism::Gaussian,
118                    sensitivity: self.sensitivity,
119                    clipping_norm: self.clipping_norm,
120                    noise_scale: sigma,
121                    noised_parameter_count: params.len() as u64,
122                }
123            }
124            NoiseMechanism::Laplace => {
125                let b = self.laplace_scale();
126                for p in params.iter_mut() {
127                    // Laplace noise via inverse CDF: b * sign(u-0.5) * ln(1 - 2|u-0.5|)
128                    let u: f64 = self.rng.gen::<f64>() - 0.5;
129                    let noise = -b * u.signum() * (1.0 - 2.0 * u.abs()).ln();
130                    *p += noise;
131                }
132                DiffPrivacyProof {
133                    epsilon: self.epsilon,
134                    delta: 0.0,
135                    mechanism: NoiseMechanism::Laplace,
136                    sensitivity: self.sensitivity,
137                    clipping_norm: self.clipping_norm,
138                    noise_scale: b,
139                    noised_parameter_count: params.len() as u64,
140                }
141            }
142        }
143    }
144
145    /// Add noise to a single scalar value.
146    pub fn add_noise_scalar(&mut self, value: &mut f64) -> f64 {
147        let mut v = [*value];
148        self.add_noise(&mut v);
149        *value = v[0];
150        v[0]
151    }
152
153    /// Current epsilon setting.
154    pub fn epsilon(&self) -> f64 {
155        self.epsilon
156    }
157
158    /// Current delta setting.
159    pub fn delta(&self) -> f64 {
160        self.delta
161    }
162}
163
164// -- Privacy Accountant (RDP) ------------------------------------------------
165
166/// Renyi Differential Privacy (RDP) accountant for tracking cumulative privacy loss.
167///
168/// Tracks privacy budget across multiple export rounds using RDP composition,
169/// which provides tighter bounds than naive (epsilon, delta)-DP composition.
170pub struct PrivacyAccountant {
171    /// Maximum allowed cumulative epsilon.
172    epsilon_limit: f64,
173    /// Target delta for conversion from RDP to (epsilon, delta)-DP.
174    target_delta: f64,
175    /// Accumulated RDP values at various alpha orders.
176    /// Each entry: (alpha_order, accumulated_rdp_epsilon)
177    rdp_alphas: Vec<(f64, f64)>,
178    /// History of exports: (timestamp, epsilon_spent, mechanism).
179    history: Vec<ExportRecord>,
180}
181
182/// Record of a single privacy-consuming export.
183#[derive(Clone, Debug)]
184pub struct ExportRecord {
185    /// UNIX timestamp of the export.
186    pub timestamp_s: u64,
187    /// Epsilon consumed by this export.
188    pub epsilon: f64,
189    /// Delta for this export (0 for pure epsilon-DP).
190    pub delta: f64,
191    /// Mechanism used.
192    pub mechanism: NoiseMechanism,
193    /// Number of parameters.
194    pub parameter_count: u64,
195}
196
197impl PrivacyAccountant {
198    /// Create a new accountant with the given budget.
199    pub fn new(epsilon_limit: f64, target_delta: f64) -> Self {
200        // Standard RDP alpha orders for accounting
201        let alphas: Vec<f64> = vec![
202            1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0,
203            1024.0,
204        ];
205        let rdp_alphas = alphas.into_iter().map(|a| (a, 0.0)).collect();
206        Self {
207            epsilon_limit,
208            target_delta,
209            rdp_alphas,
210            history: Vec::new(),
211        }
212    }
213
214    /// Compute RDP epsilon for the Gaussian mechanism at a given alpha order.
215    fn gaussian_rdp(alpha: f64, sigma: f64) -> f64 {
216        alpha / (2.0 * sigma * sigma)
217    }
218
219    /// Convert RDP to (epsilon, delta)-DP for a given alpha order.
220    fn rdp_to_dp(alpha: f64, rdp_epsilon: f64, delta: f64) -> f64 {
221        rdp_epsilon - (delta.ln()) / (alpha - 1.0)
222    }
223
224    /// Record a Gaussian mechanism query.
225    pub fn record_gaussian(&mut self, sigma: f64, epsilon: f64, delta: f64, parameter_count: u64) {
226        // Accumulate RDP at each alpha order
227        for (alpha, rdp_eps) in &mut self.rdp_alphas {
228            *rdp_eps += Self::gaussian_rdp(*alpha, sigma);
229        }
230        self.history.push(ExportRecord {
231            timestamp_s: 0,
232            epsilon,
233            delta,
234            mechanism: NoiseMechanism::Gaussian,
235            parameter_count,
236        });
237    }
238
239    /// Record a Laplace mechanism query.
240    pub fn record_laplace(&mut self, epsilon: f64, parameter_count: u64) {
241        // For Laplace, RDP epsilon at order alpha is: alpha * eps / (alpha - 1)
242        // when alpha > 1
243        for (alpha, rdp_eps) in &mut self.rdp_alphas {
244            if *alpha > 1.0 {
245                *rdp_eps += *alpha * epsilon / (*alpha - 1.0);
246            }
247        }
248        self.history.push(ExportRecord {
249            timestamp_s: 0,
250            epsilon,
251            delta: 0.0,
252            mechanism: NoiseMechanism::Laplace,
253            parameter_count,
254        });
255    }
256
257    /// Get the current best (tightest) epsilon estimate.
258    pub fn current_epsilon(&self) -> f64 {
259        self.rdp_alphas
260            .iter()
261            .map(|(alpha, rdp_eps)| Self::rdp_to_dp(*alpha, *rdp_eps, self.target_delta))
262            .fold(f64::INFINITY, f64::min)
263    }
264
265    /// Remaining privacy budget.
266    pub fn remaining_budget(&self) -> f64 {
267        (self.epsilon_limit - self.current_epsilon()).max(0.0)
268    }
269
270    /// Check if we can afford another export with the given epsilon.
271    pub fn can_afford(&self, additional_epsilon: f64) -> bool {
272        self.current_epsilon() + additional_epsilon <= self.epsilon_limit
273    }
274
275    /// Check if budget is exhausted.
276    pub fn is_exhausted(&self) -> bool {
277        self.current_epsilon() >= self.epsilon_limit
278    }
279
280    /// Fraction of budget consumed (0.0 to 1.0+).
281    pub fn budget_fraction_used(&self) -> f64 {
282        self.current_epsilon() / self.epsilon_limit
283    }
284
285    /// Number of exports recorded.
286    pub fn export_count(&self) -> usize {
287        self.history.len()
288    }
289
290    /// Export history.
291    pub fn history(&self) -> &[ExportRecord] {
292        &self.history
293    }
294
295    /// Epsilon limit.
296    pub fn epsilon_limit(&self) -> f64 {
297        self.epsilon_limit
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn gaussian_engine_creates() {
307        let engine = DiffPrivacyEngine::gaussian(1.0, 1e-5, 1.0, 1.0);
308        assert!(engine.is_ok());
309    }
310
311    #[test]
312    fn invalid_epsilon_rejected() {
313        let engine = DiffPrivacyEngine::gaussian(0.0, 1e-5, 1.0, 1.0);
314        assert!(engine.is_err());
315        let engine = DiffPrivacyEngine::gaussian(-1.0, 1e-5, 1.0, 1.0);
316        assert!(engine.is_err());
317    }
318
319    #[test]
320    fn invalid_delta_rejected() {
321        let engine = DiffPrivacyEngine::gaussian(1.0, 0.0, 1.0, 1.0);
322        assert!(engine.is_err());
323        let engine = DiffPrivacyEngine::gaussian(1.0, 1.0, 1.0, 1.0);
324        assert!(engine.is_err());
325    }
326
327    #[test]
328    fn gradient_clipping() {
329        let engine = DiffPrivacyEngine::gaussian(1.0, 1e-5, 1.0, 1.0).unwrap();
330        let mut grads = vec![3.0, 4.0]; // norm = 5.0
331        engine.clip_gradients(&mut grads);
332        let norm: f64 = grads.iter().map(|x| x * x).sum::<f64>().sqrt();
333        assert!((norm - 1.0).abs() < 1e-6); // clipped to norm 1.0
334    }
335
336    #[test]
337    fn gradient_no_clip_when_small() {
338        let engine = DiffPrivacyEngine::gaussian(1.0, 1e-5, 1.0, 10.0).unwrap();
339        let mut grads = vec![3.0, 4.0]; // norm = 5.0, clip = 10.0
340        engine.clip_gradients(&mut grads);
341        assert!((grads[0] - 3.0).abs() < 1e-10);
342        assert!((grads[1] - 4.0).abs() < 1e-10);
343    }
344
345    #[test]
346    fn add_noise_gaussian_deterministic() {
347        let mut engine = DiffPrivacyEngine::gaussian(1.0, 1e-5, 1.0, 100.0)
348            .unwrap()
349            .with_seed(42);
350        let mut params = vec![1.0, 2.0, 3.0];
351        let original = params.clone();
352        let proof = engine.add_noise(&mut params);
353        assert_eq!(proof.mechanism, NoiseMechanism::Gaussian);
354        assert_eq!(proof.noised_parameter_count, 3);
355        // Params should be different from original (noise added)
356        assert!(params
357            .iter()
358            .zip(original.iter())
359            .any(|(a, b)| (a - b).abs() > 1e-10));
360    }
361
362    #[test]
363    fn add_noise_laplace_deterministic() {
364        let mut engine = DiffPrivacyEngine::laplace(1.0, 1.0, 100.0)
365            .unwrap()
366            .with_seed(42);
367        let mut params = vec![1.0, 2.0, 3.0];
368        let proof = engine.add_noise(&mut params);
369        assert_eq!(proof.mechanism, NoiseMechanism::Laplace);
370        assert_eq!(proof.noised_parameter_count, 3);
371    }
372
373    #[test]
374    fn privacy_accountant_initial_state() {
375        let acc = PrivacyAccountant::new(10.0, 1e-5);
376        assert_eq!(acc.export_count(), 0);
377        assert!(!acc.is_exhausted());
378        assert!(acc.can_afford(1.0));
379        assert!(acc.remaining_budget() > 9.9);
380    }
381
382    #[test]
383    fn privacy_accountant_tracks_gaussian() {
384        let mut acc = PrivacyAccountant::new(10.0, 1e-5);
385        // sigma=1.0 with epsilon=1.0 per query
386        acc.record_gaussian(1.0, 1.0, 1e-5, 100);
387        assert_eq!(acc.export_count(), 1);
388        let eps = acc.current_epsilon();
389        assert!(eps > 0.0);
390        assert!(eps < 10.0);
391    }
392
393    #[test]
394    fn privacy_accountant_composition() {
395        let mut acc = PrivacyAccountant::new(10.0, 1e-5);
396        let eps_after_1 = {
397            acc.record_gaussian(1.0, 1.0, 1e-5, 100);
398            acc.current_epsilon()
399        };
400        acc.record_gaussian(1.0, 1.0, 1e-5, 100);
401        let eps_after_2 = acc.current_epsilon();
402        // After 2 queries, epsilon should be larger
403        assert!(eps_after_2 > eps_after_1);
404    }
405
406    #[test]
407    fn privacy_accountant_exhaustion() {
408        let mut acc = PrivacyAccountant::new(1.0, 1e-5);
409        // Use a very small sigma to burn budget fast
410        for _ in 0..100 {
411            acc.record_gaussian(0.1, 10.0, 1e-5, 10);
412        }
413        assert!(acc.is_exhausted());
414        assert!(!acc.can_afford(0.1));
415    }
416}