optirs_core/privacy/federated/
composition_analyzer.rs1use crate::error::{OptimError, Result};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum FederatedCompositionMethod {
19 Basic,
21
22 AdvancedComposition,
27
28 #[default]
34 FederatedMomentsAccountant,
35
36 RenyiDP,
41
42 ZCDP,
49}
50
51pub struct FederatedCompositionAnalyzer {
53 method: FederatedCompositionMethod,
54 round_compositions: Vec<RoundComposition>,
55 client_compositions: HashMap<String, Vec<ClientComposition>>,
56}
57
58#[derive(Debug, Clone)]
60pub struct RoundComposition {
61 pub round: usize,
62 pub participating_clients: usize,
63 pub epsilonconsumed: f64,
64 pub delta_consumed: f64,
65 pub amplification_applied: bool,
66 pub composition_method: FederatedCompositionMethod,
67}
68
69#[derive(Debug, Clone)]
71pub struct ClientComposition {
72 pub clientid: String,
73 pub round: usize,
74 pub epsilon_contribution: f64,
75 pub delta_contribution: f64,
76}
77
78#[derive(Debug, Clone)]
80pub struct CompositionStats {
81 pub total_rounds: usize,
82 pub total_epsilon_consumed: f64,
83 pub total_delta_consumed: f64,
84 pub composition_method: FederatedCompositionMethod,
85 pub amplification_rounds: usize,
86}
87
88impl FederatedCompositionAnalyzer {
89 pub fn new(method: FederatedCompositionMethod) -> Self {
90 Self {
91 method,
92 round_compositions: Vec::new(),
93 client_compositions: HashMap::new(),
94 }
95 }
96
97 pub fn analyze_composition(&self, round: usize, epsilon: f64, delta: f64) -> Result<f64> {
111 validate_composition_params(round, epsilon, delta)?;
112 let k = round as f64;
113
114 let total_epsilon = match self.method {
115 FederatedCompositionMethod::Basic => k * epsilon,
116 FederatedCompositionMethod::AdvancedComposition
120 | FederatedCompositionMethod::FederatedMomentsAccountant
121 | FederatedCompositionMethod::RenyiDP => {
122 advanced_composition_epsilon(k, epsilon, delta)
123 }
124 FederatedCompositionMethod::ZCDP => zcdp_composition_epsilon(k, epsilon, delta),
125 };
126
127 if !total_epsilon.is_finite() {
128 return Err(OptimError::InvalidParameter(format!(
129 "composed epsilon is not finite (round={round}, epsilon={epsilon}, delta={delta})"
130 )));
131 }
132
133 Ok(total_epsilon)
134 }
135
136 pub fn add_round_composition(&mut self, composition: RoundComposition) {
137 self.round_compositions.push(composition);
138 }
139
140 pub fn add_client_composition(&mut self, client_id: String, composition: ClientComposition) {
141 self.client_compositions
142 .entry(client_id)
143 .or_default()
144 .push(composition);
145 }
146
147 pub fn get_composition_stats(&self) -> CompositionStats {
148 if self.round_compositions.is_empty() {
149 return CompositionStats::default();
150 }
151
152 let total_epsilon: f64 = self
153 .round_compositions
154 .iter()
155 .map(|comp| comp.epsilonconsumed)
156 .sum();
157
158 let total_delta: f64 = self
159 .round_compositions
160 .iter()
161 .map(|comp| comp.delta_consumed)
162 .sum();
163
164 CompositionStats {
165 total_rounds: self.round_compositions.len(),
166 total_epsilon_consumed: total_epsilon,
167 total_delta_consumed: total_delta,
168 composition_method: self.method,
169 amplification_rounds: self
170 .round_compositions
171 .iter()
172 .filter(|comp| comp.amplification_applied)
173 .count(),
174 }
175 }
176
177 pub fn method(&self) -> FederatedCompositionMethod {
179 self.method
180 }
181
182 pub fn rounds_count(&self) -> usize {
184 self.round_compositions.len()
185 }
186
187 pub fn get_client_compositions(&self, client_id: &str) -> Option<&Vec<ClientComposition>> {
189 self.client_compositions.get(client_id)
190 }
191
192 pub fn get_round_compositions(&self) -> &Vec<RoundComposition> {
194 &self.round_compositions
195 }
196
197 pub fn clear_history(&mut self) {
199 self.round_compositions.clear();
200 self.client_compositions.clear();
201 }
202
203 pub fn set_method(&mut self, method: FederatedCompositionMethod) {
205 self.method = method;
206 }
207}
208
209fn validate_composition_params(round: usize, epsilon: f64, delta: f64) -> Result<()> {
216 if !epsilon.is_finite() || epsilon <= 0.0 {
217 return Err(OptimError::InvalidParameter(format!(
218 "epsilon must be a positive finite number, got {epsilon}"
219 )));
220 }
221 if !delta.is_finite() || delta <= 0.0 || delta >= 1.0 {
222 return Err(OptimError::InvalidParameter(format!(
223 "delta must lie in the open interval (0, 1), got {delta}"
224 )));
225 }
226 if round < 1 {
227 return Err(OptimError::InvalidParameter(
228 "round must be at least 1".to_string(),
229 ));
230 }
231 Ok(())
232}
233
234fn advanced_composition_epsilon(k: f64, epsilon: f64, delta: f64) -> f64 {
244 let advanced =
245 (2.0 * k * (1.0 / delta).ln()).sqrt() * epsilon + k * epsilon * (epsilon.exp() - 1.0);
246 advanced.min(k * epsilon)
247}
248
249fn zcdp_composition_epsilon(k: f64, epsilon: f64, delta: f64) -> f64 {
261 let a = (1.0 / delta).ln();
262 let u = (a + epsilon).sqrt() - a.sqrt();
263 let rho = u * u;
264 let rho_total = k * rho;
265 rho_total + 2.0 * (rho_total * a).sqrt()
266}
267
268impl Default for CompositionStats {
269 fn default() -> Self {
270 Self {
271 total_rounds: 0,
272 total_epsilon_consumed: 0.0,
273 total_delta_consumed: 0.0,
274 composition_method: FederatedCompositionMethod::default(),
275 amplification_rounds: 0,
276 }
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn test_federated_composition_analyzer() {
286 let analyzer =
287 FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition);
288
289 let epsilon = analyzer
290 .analyze_composition(5, 0.1, 1e-5)
291 .expect("unwrap failed");
292 assert!(epsilon > 0.1); }
294
295 #[test]
296 fn test_composition_stats() {
297 let mut analyzer = FederatedCompositionAnalyzer::new(
298 FederatedCompositionMethod::FederatedMomentsAccountant,
299 );
300
301 analyzer.add_round_composition(RoundComposition {
303 round: 1,
304 participating_clients: 10,
305 epsilonconsumed: 0.1,
306 delta_consumed: 1e-5,
307 amplification_applied: true,
308 composition_method: FederatedCompositionMethod::FederatedMomentsAccountant,
309 });
310
311 analyzer.add_round_composition(RoundComposition {
312 round: 2,
313 participating_clients: 12,
314 epsilonconsumed: 0.15,
315 delta_consumed: 1e-5,
316 amplification_applied: false,
317 composition_method: FederatedCompositionMethod::FederatedMomentsAccountant,
318 });
319
320 let stats = analyzer.get_composition_stats();
321 assert_eq!(stats.total_rounds, 2);
322 assert_eq!(stats.total_epsilon_consumed, 0.25);
323 assert_eq!(stats.total_delta_consumed, 2e-5);
324 assert_eq!(stats.amplification_rounds, 1);
325 }
326
327 #[test]
328 fn test_basic_composition() {
329 let analyzer = FederatedCompositionAnalyzer::new(FederatedCompositionMethod::Basic);
330 let epsilon = analyzer
331 .analyze_composition(3, 0.1, 1e-5)
332 .expect("unwrap failed");
333 assert!((epsilon - 0.3).abs() < 1e-10); }
335
336 #[test]
337 fn test_client_composition_tracking() {
338 let mut analyzer = FederatedCompositionAnalyzer::new(
339 FederatedCompositionMethod::FederatedMomentsAccountant,
340 );
341
342 let client_comp = ClientComposition {
343 clientid: "client1".to_string(),
344 round: 1,
345 epsilon_contribution: 0.05,
346 delta_contribution: 5e-6,
347 };
348
349 analyzer.add_client_composition("client1".to_string(), client_comp);
350
351 let compositions = analyzer.get_client_compositions("client1");
352 assert!(compositions.is_some());
353 assert_eq!(compositions.expect("unwrap failed").len(), 1);
354 }
355
356 #[test]
357 fn test_clear_history() {
358 let mut analyzer = FederatedCompositionAnalyzer::new(
359 FederatedCompositionMethod::FederatedMomentsAccountant,
360 );
361
362 analyzer.add_round_composition(RoundComposition {
364 round: 1,
365 participating_clients: 10,
366 epsilonconsumed: 0.1,
367 delta_consumed: 1e-5,
368 amplification_applied: true,
369 composition_method: FederatedCompositionMethod::FederatedMomentsAccountant,
370 });
371
372 assert_eq!(analyzer.rounds_count(), 1);
373
374 analyzer.clear_history();
375 assert_eq!(analyzer.rounds_count(), 0);
376 }
377
378 #[test]
382 fn test_advanced_composition_golden_value() {
383 let analyzer =
384 FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition);
385 let eps = analyzer
386 .analyze_composition(1000, 0.1, 1e-5)
387 .expect("valid params");
388 assert!(
389 (eps - 25.69).abs() < 0.1,
390 "advanced composition = {eps}, expected ≈25.69"
391 );
392 assert!(eps > 5.03, "must exceed the old buggy 5.03 under-report");
394 assert!(eps <= 100.0);
396 }
397
398 #[test]
401 fn test_moments_and_renyi_are_conservative() {
402 let advanced =
403 FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition)
404 .analyze_composition(1000, 0.1, 1e-5)
405 .expect("valid");
406
407 for method in [
408 FederatedCompositionMethod::FederatedMomentsAccountant,
409 FederatedCompositionMethod::RenyiDP,
410 ] {
411 let eps = FederatedCompositionAnalyzer::new(method)
412 .analyze_composition(1000, 0.1, 1e-5)
413 .expect("valid");
414 assert!(
415 (eps - advanced).abs() < 1e-9,
416 "{method:?} must equal the advanced-composition bound, got {eps}"
417 );
418 assert!(eps > 3.16, "{method:?} must exceed old ε·√k under-report");
419 }
420 }
421
422 #[test]
426 fn test_zcdp_composition_golden_value() {
427 let analyzer = FederatedCompositionAnalyzer::new(FederatedCompositionMethod::ZCDP);
428 let one = analyzer.analyze_composition(1, 0.1, 1e-5).expect("valid");
429 let many = analyzer
430 .analyze_composition(1000, 0.1, 1e-5)
431 .expect("valid");
432 assert!(one > 0.0 && one.is_finite());
433 assert!(one >= 0.1 - 1e-9, "single-round zCDP ε = {one}");
435 assert!(many > one, "zCDP must accumulate across rounds");
436 assert!(
437 (many - 3.38).abs() < 0.05,
438 "zCDP composition = {many}, expected ≈3.38"
439 );
440 }
441
442 #[test]
445 fn test_advanced_composition_single_round_matches_basic() {
446 let analyzer =
447 FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition);
448 let eps = analyzer.analyze_composition(1, 0.5, 1e-5).expect("valid");
449 assert!(
450 (eps - 0.5).abs() < 1e-12,
451 "k=1 advanced should equal ε, got {eps}"
452 );
453 }
454
455 #[test]
458 fn test_invalid_parameters_are_rejected() {
459 let analyzer =
460 FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition);
461 assert!(analyzer.analyze_composition(10, 0.1, 0.0).is_err());
463 assert!(analyzer.analyze_composition(10, 0.1, 1.0).is_err());
465 assert!(analyzer.analyze_composition(10, 0.1, -1e-5).is_err());
467 assert!(analyzer.analyze_composition(10, 0.0, 1e-5).is_err());
469 assert!(analyzer.analyze_composition(10, -0.1, 1e-5).is_err());
470 assert!(analyzer.analyze_composition(0, 0.1, 1e-5).is_err());
472 assert!(analyzer.analyze_composition(10, f64::NAN, 1e-5).is_err());
474 }
475}