Skip to main content

optirs_core/privacy/federated/
composition_analyzer.rs

1// Federated Composition Analyzer Module
2//
3// This module implements privacy composition analysis for federated learning,
4// tracking privacy budget consumption across multiple rounds and providing
5// various composition methods for differential privacy guarantees.
6
7use crate::error::{OptimError, Result};
8use std::collections::HashMap;
9
10/// Federated composition methods.
11///
12/// Every variant treats the caller-supplied per-round `(epsilon, delta)` as a
13/// black-box `(ε, δ)`-DP guarantee and composes it over `round` applications.
14/// Mechanism-specific accountants (moments / RDP) that need the noise multiplier
15/// `σ` and the sampling rate `q` cannot be reconstructed from `(ε, δ)` alone; for
16/// tight per-step RDP accounting use [`crate::privacy::accountant`] instead.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum FederatedCompositionMethod {
19    /// Basic (linear) composition: `ε' = k·ε`. Always a valid upper bound.
20    Basic,
21
22    /// Advanced composition (Dwork–Roth, Thm 3.20):
23    /// `ε' = √(2k·ln(1/δ'))·ε + k·ε·(e^ε − 1)`, returned as `min(ε', k·ε)`.
24    /// The caller's `delta` plays the role of the advanced-composition slack `δ'`;
25    /// the composed mechanism's total failure probability is `k·δ_round + δ'`.
26    AdvancedComposition,
27
28    /// Moments-accountant-style composition.
29    ///
30    /// A true moments accountant needs `σ` and `q`, which `(ε, δ)` does not carry,
31    /// so this returns the conservative advanced-composition bound (an upper bound,
32    /// never an under-estimate). Use [`crate::privacy::accountant`] for tight RDP.
33    #[default]
34    FederatedMomentsAccountant,
35
36    /// Rényi-DP-style composition.
37    ///
38    /// Like [`Self::FederatedMomentsAccountant`], RDP needs `σ`/`q`; this returns the
39    /// conservative advanced-composition bound. Use [`crate::privacy::accountant`].
40    RenyiDP,
41
42    /// Zero-concentrated DP composition.
43    ///
44    /// Interprets the per-round `(ε, δ)` as a `ρ`-zCDP guarantee (the largest `ρ`
45    /// consistent with `ε = ρ + 2√(ρ·ln(1/δ))`), composes `ρ_total = k·ρ`, and
46    /// converts back. Only valid when the per-round mechanism really is `ρ`-zCDP
47    /// (e.g. Gaussian); otherwise prefer [`Self::AdvancedComposition`].
48    ZCDP,
49}
50
51/// Federated composition analyzer
52pub struct FederatedCompositionAnalyzer {
53    method: FederatedCompositionMethod,
54    round_compositions: Vec<RoundComposition>,
55    client_compositions: HashMap<String, Vec<ClientComposition>>,
56}
57
58/// Round composition for privacy accounting
59#[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/// Client-specific composition tracking
70#[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/// Composition statistics
79#[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    /// Compose the per-round `(epsilon, delta)` guarantee over `round` rounds.
98    ///
99    /// Returns the total `ε` under the configured composition method. See
100    /// [`FederatedCompositionMethod`] for the exact bound each variant applies and
101    /// its assumptions. Every returned value is a valid *upper* bound on the true
102    /// privacy loss for its declared assumptions — the function never under-reports
103    /// via the old `ε·√k` / `ε·ln(k)` heuristics, which silently voided the DP
104    /// guarantee.
105    ///
106    /// # Errors
107    /// Returns [`OptimError::InvalidParameter`] unless `epsilon > 0`,
108    /// `0 < delta < 1`, and `round >= 1`, all finite. A non-finite result is also
109    /// rejected so a NaN can never masquerade as a passing budget check.
110    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            // Mechanism-specific accountants need σ/q, which (ε, δ) lacks; the
117            // conservative advanced-composition bound is the tightest honest
118            // answer from (ε, δ) alone and never under-reports.
119            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    /// Get current composition method
178    pub fn method(&self) -> FederatedCompositionMethod {
179        self.method
180    }
181
182    /// Get number of rounds tracked
183    pub fn rounds_count(&self) -> usize {
184        self.round_compositions.len()
185    }
186
187    /// Get client composition history for a specific client
188    pub fn get_client_compositions(&self, client_id: &str) -> Option<&Vec<ClientComposition>> {
189        self.client_compositions.get(client_id)
190    }
191
192    /// Get round compositions
193    pub fn get_round_compositions(&self) -> &Vec<RoundComposition> {
194        &self.round_compositions
195    }
196
197    /// Clear all composition history
198    pub fn clear_history(&mut self) {
199        self.round_compositions.clear();
200        self.client_compositions.clear();
201    }
202
203    /// Set composition method
204    pub fn set_method(&mut self, method: FederatedCompositionMethod) {
205        self.method = method;
206    }
207}
208
209/// Validate the per-round composition parameters.
210///
211/// `epsilon > 0`, `0 < delta < 1`, `round >= 1`, all finite. Rejecting these up
212/// front is the single most important guard: without it `delta = 0` makes
213/// `ln(1/δ)` infinite and a negative `delta` yields `NaN`, and a `NaN` budget
214/// compares `false` against every threshold, so the budget check silently passes.
215fn 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
234/// Advanced composition bound (Dwork–Roth, *The Algorithmic Foundations of
235/// Differential Privacy*, Thm 3.20):
236///
237/// `ε' = √(2k·ln(1/δ'))·ε + k·ε·(e^ε − 1)`, returned as `min(ε', k·ε)`.
238///
239/// The `min` with basic composition matters at small `k`, where the advanced
240/// bound can exceed the trivial linear one. Callers must have validated the
241/// parameters (see [`validate_composition_params`]); with `0 < delta < 1` the
242/// `ln(1/delta)` term is finite and positive.
243fn 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
249/// Zero-concentrated DP composition.
250///
251/// Interprets the per-round `(ε, δ)` as `ρ`-zCDP with the largest `ρ` consistent
252/// with the tight conversion `ε = ρ + 2√(ρ·ln(1/δ))`. Writing `a = ln(1/δ)` and
253/// `u = √ρ`, that quadratic solves to `u = √(a + ε) − √a`, so
254/// `ρ = (√(a + ε) − √a)²`. zCDP composes additively, `ρ_total = k·ρ`, and converts
255/// back with `ε_total = ρ_total + 2√(ρ_total·a)`.
256///
257/// Taking the largest consistent `ρ` makes the composed `ε` the most conservative
258/// (largest) value, so this never under-reports under the zCDP assumption. Callers
259/// must have validated the parameters; `a > 0` because `0 < delta < 1`.
260fn 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); // Should be larger than single round epsilon
293    }
294
295    #[test]
296    fn test_composition_stats() {
297        let mut analyzer = FederatedCompositionAnalyzer::new(
298            FederatedCompositionMethod::FederatedMomentsAccountant,
299        );
300
301        // Add some round compositions
302        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); // Basic composition: 3 * 0.1, with floating point tolerance
334    }
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        // Add some data
363        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    /// Golden value: advanced composition at k=1000, ε=0.1, δ=1e-5.
379    /// Dwork–Roth Thm 3.20 gives ≈25.69; the old buggy `sqrt(...)` heuristic
380    /// returned ≈5.03 (a ~5× under-report that voided the DP guarantee).
381    #[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        // Must never fall back to the old under-reporting value.
393        assert!(eps > 5.03, "must exceed the old buggy 5.03 under-report");
394        // And must never exceed basic composition (k·ε = 100).
395        assert!(eps <= 100.0);
396    }
397
398    /// Moments/Rényi variants must not under-report: they return the conservative
399    /// advanced-composition bound, never the old `ε·√k` (≈3.16) or `ε·ln(k)`.
400    #[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    /// zCDP composition: proper ε→ρ inversion, composition, and back-conversion.
423    /// Golden value at k=1000, ε=0.1, δ=1e-5 is ≈3.38 (hand-computed), pinned so a
424    /// future algebra regression in the inversion is caught.
425    #[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        // A single ρ-round returns an ε at least the input.
434        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    /// At k=1 advanced composition must not undercut basic composition (the `min`
443    /// with `k·ε` is what guarantees this).
444    #[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    /// F36: invalid parameters must be rejected, never silently returned as a NaN
456    /// or infinite budget that passes every downstream threshold check.
457    #[test]
458    fn test_invalid_parameters_are_rejected() {
459        let analyzer =
460            FederatedCompositionAnalyzer::new(FederatedCompositionMethod::AdvancedComposition);
461        // delta = 0 (the old code produced +inf here).
462        assert!(analyzer.analyze_composition(10, 0.1, 0.0).is_err());
463        // delta >= 1.
464        assert!(analyzer.analyze_composition(10, 0.1, 1.0).is_err());
465        // negative delta (the old code produced NaN).
466        assert!(analyzer.analyze_composition(10, 0.1, -1e-5).is_err());
467        // non-positive epsilon.
468        assert!(analyzer.analyze_composition(10, 0.0, 1e-5).is_err());
469        assert!(analyzer.analyze_composition(10, -0.1, 1e-5).is_err());
470        // round = 0.
471        assert!(analyzer.analyze_composition(0, 0.1, 1e-5).is_err());
472        // non-finite epsilon.
473        assert!(analyzer.analyze_composition(10, f64::NAN, 1e-5).is_err());
474    }
475}