Skip to main content

ipfrs_semantic/
privacy.rs

1//! Differential privacy for embeddings
2//!
3//! This module provides privacy-preserving mechanisms for embedding-based search:
4//! - Noise injection (Laplacian, Gaussian)
5//! - Privacy budget tracking (epsilon-delta)
6//! - Utility-privacy trade-off analysis
7//! - Secure embedding release
8
9use ipfrs_core::{Error, Result};
10use serde::{Deserialize, Serialize};
11use std::sync::{Arc, Mutex};
12
13/// Noise distribution for differential privacy
14#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
15pub enum NoiseDistribution {
16    /// Laplacian noise (for epsilon-DP)
17    Laplacian { scale: f32 },
18    /// Gaussian noise (for (epsilon, delta)-DP)
19    Gaussian { sigma: f32 },
20}
21
22/// Privacy mechanism for adding noise to embeddings
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PrivacyMechanism {
25    /// Noise distribution
26    distribution: NoiseDistribution,
27    /// Privacy budget (epsilon)
28    epsilon: f32,
29    /// Privacy parameter delta (for Gaussian)
30    delta: f32,
31    /// Sensitivity of the query (L2 norm bound)
32    sensitivity: f32,
33}
34
35impl PrivacyMechanism {
36    /// Create a new Laplacian mechanism (epsilon-DP)
37    pub fn laplacian(epsilon: f32, sensitivity: f32) -> Result<Self> {
38        if epsilon <= 0.0 {
39            return Err(Error::InvalidInput("Epsilon must be positive".into()));
40        }
41        if sensitivity <= 0.0 {
42            return Err(Error::InvalidInput("Sensitivity must be positive".into()));
43        }
44
45        let scale = sensitivity / epsilon;
46
47        Ok(Self {
48            distribution: NoiseDistribution::Laplacian { scale },
49            epsilon,
50            delta: 0.0,
51            sensitivity,
52        })
53    }
54
55    /// Create a new Gaussian mechanism ((epsilon, delta)-DP)
56    pub fn gaussian(epsilon: f32, delta: f32, sensitivity: f32) -> Result<Self> {
57        if epsilon <= 0.0 {
58            return Err(Error::InvalidInput("Epsilon must be positive".into()));
59        }
60        if delta <= 0.0 || delta >= 1.0 {
61            return Err(Error::InvalidInput("Delta must be in (0, 1)".into()));
62        }
63        if sensitivity <= 0.0 {
64            return Err(Error::InvalidInput("Sensitivity must be positive".into()));
65        }
66
67        // Calculate sigma using the Gaussian mechanism formula
68        // sigma = sensitivity * sqrt(2 * ln(1.25 / delta)) / epsilon
69        let sigma = sensitivity * (2.0 * (1.25 / delta).ln()).sqrt() / epsilon;
70
71        Ok(Self {
72            distribution: NoiseDistribution::Gaussian { sigma },
73            epsilon,
74            delta,
75            sensitivity,
76        })
77    }
78
79    /// Add noise to an embedding
80    pub fn add_noise(&self, embedding: &[f32]) -> Vec<f32> {
81        use rand::RngExt;
82        let mut rng = rand::rng();
83
84        match self.distribution {
85            NoiseDistribution::Laplacian { scale } => embedding
86                .iter()
87                .map(|&x| x + sample_laplacian(&mut rng, scale))
88                .collect(),
89            NoiseDistribution::Gaussian { sigma } => {
90                embedding
91                    .iter()
92                    .map(|&x| {
93                        // Sample from N(0, sigma^2)
94                        let noise: f32 = rng.random_range(-1.0..1.0);
95                        x + noise * sigma
96                    })
97                    .collect()
98            }
99        }
100    }
101
102    /// Get the privacy budget (epsilon)
103    pub fn epsilon(&self) -> f32 {
104        self.epsilon
105    }
106
107    /// Get the delta parameter
108    pub fn delta(&self) -> f32 {
109        self.delta
110    }
111
112    /// Calculate expected utility loss (L2 distance to original)
113    pub fn expected_utility_loss(&self, dimension: usize) -> f32 {
114        match self.distribution {
115            NoiseDistribution::Laplacian { scale } => {
116                // Expected L2 norm of Laplacian noise in d dimensions
117                // E[||noise||_2] ≈ scale * sqrt(d)
118                scale * (dimension as f32).sqrt()
119            }
120            NoiseDistribution::Gaussian { sigma } => {
121                // Expected L2 norm of Gaussian noise in d dimensions
122                // E[||noise||_2] = sigma * sqrt(d)
123                sigma * (dimension as f32).sqrt()
124            }
125        }
126    }
127}
128
129/// Sample from Laplacian distribution with scale parameter
130fn sample_laplacian<R: rand::RngExt>(rng: &mut R, scale: f32) -> f32 {
131    let u: f32 = rng.random_range(-0.5..0.5);
132    if u >= 0.0 {
133        -scale * (1.0 - 2.0 * u).ln()
134    } else {
135        scale * (1.0 + 2.0 * u).ln()
136    }
137}
138
139/// Privacy budget tracker for managing cumulative privacy loss
140pub struct PrivacyBudget {
141    /// Total budget (epsilon)
142    total_epsilon: f32,
143    /// Remaining budget
144    remaining_epsilon: Arc<Mutex<f32>>,
145    /// Total delta
146    total_delta: f32,
147    /// Queries made
148    queries: Arc<Mutex<Vec<QueryRecord>>>,
149}
150
151/// Record of a privacy-consuming query
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct QueryRecord {
154    /// Epsilon consumed
155    pub epsilon: f32,
156    /// Delta consumed
157    pub delta: f32,
158    /// Timestamp
159    pub timestamp: std::time::SystemTime,
160}
161
162impl PrivacyBudget {
163    /// Create a new privacy budget
164    pub fn new(total_epsilon: f32, total_delta: f32) -> Result<Self> {
165        if total_epsilon <= 0.0 {
166            return Err(Error::InvalidInput("Total epsilon must be positive".into()));
167        }
168
169        Ok(Self {
170            total_epsilon,
171            remaining_epsilon: Arc::new(Mutex::new(total_epsilon)),
172            total_delta,
173            queries: Arc::new(Mutex::new(Vec::new())),
174        })
175    }
176
177    /// Check if we can afford a query with given epsilon/delta
178    pub fn can_afford(&self, epsilon: f32, delta: f32) -> bool {
179        let remaining = self
180            .remaining_epsilon
181            .lock()
182            .unwrap_or_else(|e| e.into_inner());
183        *remaining >= epsilon && self.total_delta >= delta
184    }
185
186    /// Consume budget for a query
187    pub fn consume(&self, epsilon: f32, delta: f32) -> Result<()> {
188        if !self.can_afford(epsilon, delta) {
189            return Err(Error::InvalidInput("Insufficient privacy budget".into()));
190        }
191
192        let mut remaining = self
193            .remaining_epsilon
194            .lock()
195            .unwrap_or_else(|e| e.into_inner());
196        *remaining -= epsilon;
197
198        let mut queries = self.queries.lock().unwrap_or_else(|e| e.into_inner());
199        queries.push(QueryRecord {
200            epsilon,
201            delta,
202            timestamp: std::time::SystemTime::now(),
203        });
204
205        Ok(())
206    }
207
208    /// Get remaining epsilon
209    pub fn remaining(&self) -> f32 {
210        *self
211            .remaining_epsilon
212            .lock()
213            .unwrap_or_else(|e| e.into_inner())
214    }
215
216    /// Get statistics
217    pub fn stats(&self) -> PrivacyBudgetStats {
218        let remaining = *self
219            .remaining_epsilon
220            .lock()
221            .unwrap_or_else(|e| e.into_inner());
222        let queries = self.queries.lock().unwrap_or_else(|e| e.into_inner());
223
224        PrivacyBudgetStats {
225            total_epsilon: self.total_epsilon,
226            remaining_epsilon: remaining,
227            consumed_epsilon: self.total_epsilon - remaining,
228            total_delta: self.total_delta,
229            num_queries: queries.len(),
230        }
231    }
232}
233
234/// Statistics about privacy budget usage
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct PrivacyBudgetStats {
237    /// Total privacy budget
238    pub total_epsilon: f32,
239    /// Remaining budget
240    pub remaining_epsilon: f32,
241    /// Consumed budget
242    pub consumed_epsilon: f32,
243    /// Total delta
244    pub total_delta: f32,
245    /// Number of queries made
246    pub num_queries: usize,
247}
248
249/// Privacy-preserving embedding wrapper
250pub struct PrivateEmbedding {
251    /// Original embedding (kept private)
252    #[allow(dead_code)]
253    original: Vec<f32>,
254    /// Noisy version (public)
255    pub noisy: Vec<f32>,
256    /// Privacy mechanism used
257    mechanism: PrivacyMechanism,
258}
259
260impl PrivateEmbedding {
261    /// Create a private embedding with noise
262    pub fn new(embedding: Vec<f32>, mechanism: PrivacyMechanism) -> Self {
263        let noisy = mechanism.add_noise(&embedding);
264
265        Self {
266            original: embedding,
267            noisy,
268            mechanism,
269        }
270    }
271
272    /// Get the noisy (public) embedding
273    pub fn public_embedding(&self) -> &[f32] {
274        &self.noisy
275    }
276
277    /// Get the privacy parameters
278    pub fn privacy_params(&self) -> (f32, f32) {
279        (self.mechanism.epsilon(), self.mechanism.delta())
280    }
281
282    /// Get expected utility loss
283    pub fn utility_loss(&self) -> f32 {
284        self.mechanism.expected_utility_loss(self.noisy.len())
285    }
286}
287
288/// Utility-privacy trade-off analyzer
289pub struct TradeoffAnalyzer {
290    /// Different epsilon values to test
291    epsilons: Vec<f32>,
292    /// Sensitivity
293    sensitivity: f32,
294}
295
296impl TradeoffAnalyzer {
297    /// Create a new analyzer
298    pub fn new(sensitivity: f32) -> Self {
299        // Test a range of epsilon values from 0.1 to 10.0
300        let epsilons = vec![0.1, 0.5, 1.0, 2.0, 5.0, 10.0];
301
302        Self {
303            epsilons,
304            sensitivity,
305        }
306    }
307
308    /// Analyze trade-offs for different epsilon values
309    pub fn analyze(&self, dimension: usize) -> Vec<TradeoffPoint> {
310        self.epsilons
311            .iter()
312            .map(|&epsilon| {
313                let mechanism = PrivacyMechanism::laplacian(epsilon, self.sensitivity)
314                    .expect("epsilons from preset list are all positive");
315                let utility_loss = mechanism.expected_utility_loss(dimension);
316
317                TradeoffPoint {
318                    epsilon,
319                    delta: 0.0,
320                    utility_loss,
321                }
322            })
323            .collect()
324    }
325
326    /// Find the best epsilon for a target utility loss
327    pub fn find_epsilon_for_utility(&self, dimension: usize, max_utility_loss: f32) -> Option<f32> {
328        let points = self.analyze(dimension);
329
330        points
331            .into_iter()
332            .filter(|p| p.utility_loss <= max_utility_loss)
333            .map(|p| p.epsilon)
334            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
335    }
336}
337
338/// A point in the utility-privacy trade-off space
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct TradeoffPoint {
341    /// Privacy budget (epsilon)
342    pub epsilon: f32,
343    /// Delta parameter
344    pub delta: f32,
345    /// Expected utility loss
346    pub utility_loss: f32,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_laplacian_mechanism() {
355        let mechanism =
356            PrivacyMechanism::laplacian(1.0, 1.0).expect("test: valid laplacian params");
357        assert_eq!(mechanism.epsilon(), 1.0);
358        assert_eq!(mechanism.delta(), 0.0);
359
360        let embedding = vec![1.0, 2.0, 3.0];
361        let noisy = mechanism.add_noise(&embedding);
362
363        assert_eq!(noisy.len(), embedding.len());
364        // Noisy embedding should be different from original
365        assert_ne!(noisy, embedding);
366    }
367
368    #[test]
369    fn test_gaussian_mechanism() {
370        let mechanism =
371            PrivacyMechanism::gaussian(1.0, 0.001, 1.0).expect("test: valid gaussian params");
372        assert_eq!(mechanism.epsilon(), 1.0);
373        assert!(mechanism.delta() > 0.0);
374
375        let embedding = vec![1.0, 2.0, 3.0];
376        let noisy = mechanism.add_noise(&embedding);
377
378        assert_eq!(noisy.len(), embedding.len());
379    }
380
381    #[test]
382    fn test_privacy_budget() {
383        let budget = PrivacyBudget::new(10.0, 0.001).expect("test: valid budget params");
384
385        assert!(budget.can_afford(1.0, 0.0001));
386        assert_eq!(budget.remaining(), 10.0);
387
388        budget
389            .consume(1.0, 0.0001)
390            .expect("test: consume within budget");
391        assert_eq!(budget.remaining(), 9.0);
392
393        let stats = budget.stats();
394        assert_eq!(stats.consumed_epsilon, 1.0);
395        assert_eq!(stats.num_queries, 1);
396    }
397
398    #[test]
399    fn test_budget_exhaustion() {
400        let budget = PrivacyBudget::new(1.0, 0.001).expect("test: valid budget params");
401
402        budget
403            .consume(0.5, 0.0001)
404            .expect("test: consume within budget");
405        budget
406            .consume(0.5, 0.0001)
407            .expect("test: consume within budget");
408
409        // Should fail - budget exhausted
410        assert!(budget.consume(0.1, 0.0001).is_err());
411    }
412
413    #[test]
414    fn test_private_embedding() {
415        let embedding = vec![1.0, 2.0, 3.0];
416        let mechanism =
417            PrivacyMechanism::laplacian(1.0, 1.0).expect("test: valid laplacian params");
418
419        let private_emb = PrivateEmbedding::new(embedding.clone(), mechanism);
420
421        assert_eq!(private_emb.public_embedding().len(), embedding.len());
422        assert_eq!(private_emb.privacy_params().0, 1.0);
423        assert!(private_emb.utility_loss() > 0.0);
424    }
425
426    #[test]
427    fn test_tradeoff_analyzer() {
428        let analyzer = TradeoffAnalyzer::new(1.0);
429        let points = analyzer.analyze(768);
430
431        assert!(!points.is_empty());
432        // Higher epsilon should give lower utility loss
433        assert!(
434            points[0].utility_loss
435                > points
436                    .last()
437                    .expect("test: non-empty points vec")
438                    .utility_loss
439        );
440    }
441
442    #[test]
443    fn test_find_epsilon_for_utility() {
444        let analyzer = TradeoffAnalyzer::new(1.0);
445        let epsilon = analyzer.find_epsilon_for_utility(768, 10.0);
446
447        assert!(epsilon.is_some());
448        assert!(epsilon.expect("test: epsilon found for utility bound") > 0.0);
449    }
450
451    #[test]
452    fn test_utility_loss_estimation() {
453        let mechanism =
454            PrivacyMechanism::laplacian(1.0, 1.0).expect("test: valid laplacian params");
455        let loss = mechanism.expected_utility_loss(768);
456
457        // Should be roughly sqrt(768) for unit scale
458        assert!(loss > 20.0 && loss < 30.0);
459    }
460}