Skip to main content

optirs_core/privacy/
renyi_accountant.rs

1// Renyi Differential Privacy (RDP) Accountant
2//
3// This module implements a Renyi Differential Privacy accountant for tight
4// composition of Gaussian and subsampled-Gaussian mechanisms. The RDP
5// formulation, introduced by Mironov (2017), composes linearly over iterations
6// and converts to (epsilon, delta)-DP via a tight closed-form mapping.
7//
8// The implementation follows the bounds from:
9//   * Mironov, "Renyi Differential Privacy", CSF 2017.
10//   * Wang, Balle, Kasiviswanathan, "Subsampled Renyi Differential Privacy and
11//     Analytical Moments Accountant", AISTATS 2019.
12//   * Mironov, Talwar, Zhang, "Renyi Differential Privacy of the Sampled
13//     Gaussian Mechanism", arXiv:1908.10530 (2019).
14//
15// The subsampled Gaussian bound used here is the standard tight bound for
16// integer orders, computed in log space using the log-sum-exp trick for
17// numerical stability. It is evaluated exactly for every sampling probability
18// `q > 0` -- there is deliberately no "small q" analytical shortcut, because
19// such shortcuts under-report epsilon (the one direction that silently voids a
20// DP guarantee).
21//
22// Non-integer orders are handled by evaluating the kernel at `ceil(alpha)`.
23// The Renyi divergence `D_alpha` is non-decreasing in `alpha`, so this is a
24// valid *upper* bound on the RDP at the requested order: conservative, never
25// optimistic. Interpolating (or extrapolating) between integer anchors, as
26// earlier revisions did, can fall below the true value and is not used.
27//
28// This accountant is the reference privacy accountant of the crate;
29// `moment_accountant::MomentsAccountant` composes the very same per-step kernel
30// through a heterogeneous-composition ledger.
31
32use crate::error::{OptimError, Result};
33use serde::{Deserialize, Serialize};
34
35/// Default Renyi orders tracked by the accountant.
36///
37/// These orders are the standard set used by reference implementations
38/// (Opacus, TensorFlow Privacy). The range from 1.25 to 64.0 covers the
39/// typical regime of practical DP-SGD configurations.
40pub const DEFAULT_ALPHAS: &[f64] = &[
41    1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 14.0, 16.0,
42    20.0, 24.0, 28.0, 32.0, 48.0, 64.0,
43];
44
45/// Threshold on the accumulated per-order RDP above which the accountant
46/// reports *no* privacy at all.
47///
48/// The accountant never silently clamps a privacy loss: once a contribution
49/// is not finite (or the accumulated spend crosses this threshold), the
50/// accountant latches a saturation flag and every subsequent conversion
51/// returns `epsilon = +infinity`. That is the fail-closed direction --
52/// callers comparing against a budget will see the budget as exhausted
53/// rather than believing a fabricated finite number.
54const RDP_SATURATION_THRESHOLD: f64 = 1.0e12;
55
56/// Snapshot of the current per-order RDP spend.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RdpSpend {
59    /// Renyi orders (alpha values) tracked by the accountant.
60    pub orders: Vec<f64>,
61
62    /// Accumulated RDP epsilon at each corresponding order.
63    pub epsilons: Vec<f64>,
64}
65
66/// Result of converting accumulated RDP into (epsilon, delta)-DP.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
68pub struct DpConversion {
69    /// The minimum epsilon found across all tracked orders.
70    pub epsilon: f64,
71
72    /// The target delta used for the conversion.
73    pub delta: f64,
74
75    /// The Renyi order alpha that achieved the minimum epsilon.
76    pub best_order: f64,
77}
78
79/// Renyi Differential Privacy accountant.
80///
81/// Tracks accumulated RDP epsilons across a set of Renyi orders. Each call
82/// to [`add_gaussian`](Self::add_gaussian) or
83/// [`add_subsampled_gaussian`](Self::add_subsampled_gaussian) composes the
84/// new mechanism into the running budget. Conversion to standard
85/// (epsilon, delta)-DP is performed lazily via
86/// [`to_epsilon_delta`](Self::to_epsilon_delta).
87#[derive(Debug, Clone)]
88pub struct RenyiAccountant {
89    /// Sorted ascending list of Renyi orders.
90    orders: Vec<f64>,
91
92    /// Accumulated RDP epsilon for each order in `orders`.
93    rdp_epsilons: Vec<f64>,
94
95    /// Total number of mechanism applications composed so far.
96    total_steps: usize,
97
98    /// Latched once any composed contribution overflowed the representable
99    /// range (or crossed [`RDP_SATURATION_THRESHOLD`]). While set, the
100    /// accountant reports an infinite epsilon.
101    saturated: bool,
102}
103
104impl RenyiAccountant {
105    /// Create a new accountant with a user-supplied set of Renyi orders.
106    ///
107    /// The orders are sorted ascending. All orders must be strictly greater
108    /// than 1.0 (RDP is only defined for alpha > 1). The list must not be
109    /// empty.
110    pub fn new(orders: Vec<f64>) -> Result<Self> {
111        if orders.is_empty() {
112            return Err(OptimError::InvalidParameter(
113                "RenyiAccountant requires at least one Renyi order".to_string(),
114            ));
115        }
116
117        for &alpha in &orders {
118            if !alpha.is_finite() {
119                return Err(OptimError::InvalidParameter(format!(
120                    "Renyi order must be finite, got {alpha}"
121                )));
122            }
123            if alpha <= 1.0 {
124                return Err(OptimError::InvalidParameter(format!(
125                    "Renyi order must be strictly greater than 1.0, got {alpha}"
126                )));
127            }
128        }
129
130        let mut sorted = orders;
131        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
132
133        let len = sorted.len();
134        Ok(Self {
135            orders: sorted,
136            rdp_epsilons: vec![0.0; len],
137            total_steps: 0,
138            saturated: false,
139        })
140    }
141
142    /// Create an accountant using the canonical [`DEFAULT_ALPHAS`] list.
143    pub fn with_default_orders() -> Self {
144        // Safe to construct: DEFAULT_ALPHAS is a verified non-empty,
145        // strictly-greater-than-1, sorted-ascending list.
146        let orders = DEFAULT_ALPHAS.to_vec();
147        let len = orders.len();
148        Self {
149            orders,
150            rdp_epsilons: vec![0.0; len],
151            total_steps: 0,
152            saturated: false,
153        }
154    }
155
156    /// Return the canonical default order list.
157    pub fn default_orders() -> Vec<f64> {
158        DEFAULT_ALPHAS.to_vec()
159    }
160
161    /// Compose a subsampled Gaussian mechanism into the running budget.
162    ///
163    /// Each step samples each record independently with probability
164    /// `sampling_prob`, then adds Gaussian noise with standard deviation
165    /// `noise_multiplier` to the sum of clipped per-example gradients.
166    /// `steps` such applications are composed.
167    ///
168    /// The bound used is the tight Mironov/Wang/Balle bound for the sampled
169    /// Gaussian mechanism (see module-level reference list).
170    pub fn add_subsampled_gaussian(
171        &mut self,
172        noise_multiplier: f64,
173        sampling_prob: f64,
174        steps: usize,
175    ) -> Result<()> {
176        if !noise_multiplier.is_finite() || noise_multiplier <= 0.0 {
177            return Err(OptimError::InvalidParameter(format!(
178                "noise_multiplier must be a positive finite number, got {noise_multiplier}"
179            )));
180        }
181        if !sampling_prob.is_finite() || !(0.0..=1.0).contains(&sampling_prob) {
182            return Err(OptimError::InvalidParameter(format!(
183                "sampling_prob must be in [0, 1], got {sampling_prob}"
184            )));
185        }
186
187        if steps == 0 || sampling_prob == 0.0 {
188            // No mechanism application contributes any privacy loss.
189            self.total_steps = self.total_steps.saturating_add(steps);
190            return Ok(());
191        }
192
193        let steps_f = steps as f64;
194        for (i, &alpha) in self.orders.iter().enumerate() {
195            let per_step = rdp_subsampled_gaussian_step(alpha, noise_multiplier, sampling_prob)?;
196            let contribution = per_step * steps_f;
197            let accumulated = self.rdp_epsilons[i] + contribution;
198            if !accumulated.is_finite() || accumulated > RDP_SATURATION_THRESHOLD {
199                self.saturated = true;
200            }
201            self.rdp_epsilons[i] = accumulated;
202        }
203
204        self.total_steps = self.total_steps.saturating_add(steps);
205        Ok(())
206    }
207
208    /// Compose a pure Gaussian mechanism (no subsampling) into the budget.
209    ///
210    /// The RDP of a Gaussian mechanism with noise multiplier `sigma` at
211    /// order alpha is the well-known closed form `alpha / (2 * sigma^2)`,
212    /// for all alpha > 1 (Mironov 2017, Proposition 7).
213    pub fn add_gaussian(&mut self, noise_multiplier: f64, steps: usize) -> Result<()> {
214        if !noise_multiplier.is_finite() || noise_multiplier <= 0.0 {
215            return Err(OptimError::InvalidParameter(format!(
216                "noise_multiplier must be a positive finite number, got {noise_multiplier}"
217            )));
218        }
219
220        if steps == 0 {
221            return Ok(());
222        }
223
224        let steps_f = steps as f64;
225        let variance = noise_multiplier * noise_multiplier;
226
227        for (i, &alpha) in self.orders.iter().enumerate() {
228            let per_step = alpha / (2.0 * variance);
229            let accumulated = self.rdp_epsilons[i] + per_step * steps_f;
230            if !accumulated.is_finite() || accumulated > RDP_SATURATION_THRESHOLD {
231                self.saturated = true;
232            }
233            self.rdp_epsilons[i] = accumulated;
234        }
235
236        self.total_steps = self.total_steps.saturating_add(steps);
237        Ok(())
238    }
239
240    /// Return a snapshot of the current per-order RDP spend.
241    pub fn current_spend(&self) -> RdpSpend {
242        RdpSpend {
243            orders: self.orders.clone(),
244            epsilons: self.rdp_epsilons.clone(),
245        }
246    }
247
248    /// Convert the accumulated RDP into a tight (epsilon, delta)-DP bound.
249    ///
250    /// For each tracked order alpha, computes the improved RDP-to-DP
251    /// conversion of Canonne, Kamath and Steinke (2020, Proposition 12), as
252    /// used by Opacus:
253    ///
254    /// ```text
255    /// eps(alpha) = rdp(alpha)
256    ///            + ln((alpha - 1) / alpha)
257    ///            - (ln(delta) + ln(alpha)) / (alpha - 1)
258    /// ```
259    ///
260    /// This is uniformly tighter than the classic Mironov (2017,
261    /// Proposition 3) conversion `rdp(alpha) + ln(1/delta) / (alpha - 1)`,
262    /// because both correction terms `ln(1 - 1/alpha)` and
263    /// `-ln(alpha)/(alpha - 1)` are negative. The minimum over the tracked
264    /// orders is returned.
265    ///
266    /// If the accountant has saturated (see [`is_saturated`](Self::is_saturated)),
267    /// `epsilon` is `+infinity`: the mechanism provides no usable guarantee
268    /// and the caller must treat its budget as exhausted.
269    pub fn to_epsilon_delta(&self, target_delta: f64) -> Result<DpConversion> {
270        if !target_delta.is_finite() || target_delta <= 0.0 || target_delta > 1.0 {
271            return Err(OptimError::InvalidParameter(format!(
272                "target_delta must be in (0, 1], got {target_delta}"
273            )));
274        }
275
276        if self.saturated {
277            return Ok(DpConversion {
278                epsilon: f64::INFINITY,
279                delta: target_delta,
280                best_order: self.orders[0],
281            });
282        }
283
284        // Composing nothing costs nothing. Without this guard the conversion
285        // slack (`ln(1/delta) / (alpha - 1)`) would report a positive epsilon
286        // for an accountant that has never observed a mechanism.
287        if self.rdp_epsilons.iter().all(|&e| e == 0.0) {
288            return Ok(DpConversion {
289                epsilon: 0.0,
290                delta: target_delta,
291                best_order: self.orders[self.orders.len() - 1],
292            });
293        }
294
295        let log_delta = target_delta.ln();
296
297        let mut best_epsilon = f64::INFINITY;
298        let mut best_order = self.orders[0];
299
300        for (i, &alpha) in self.orders.iter().enumerate() {
301            let candidate = self.rdp_epsilons[i] + ((alpha - 1.0) / alpha).ln()
302                - (log_delta + alpha.ln()) / (alpha - 1.0);
303            if candidate.is_finite() && candidate < best_epsilon {
304                best_epsilon = candidate;
305                best_order = alpha;
306            }
307        }
308
309        // Epsilon is non-negative by definition: clamp away tiny negatives
310        // that could only arise from floating-point round-off.
311        let epsilon = best_epsilon.max(0.0);
312
313        Ok(DpConversion {
314            epsilon,
315            delta: target_delta,
316            best_order,
317        })
318    }
319
320    /// Reset the accumulated spend back to zero.
321    pub fn reset(&mut self) {
322        for value in self.rdp_epsilons.iter_mut() {
323            *value = 0.0;
324        }
325        self.total_steps = 0;
326        self.saturated = false;
327    }
328
329    /// Whether the accumulated privacy loss overflowed the representable
330    /// range. Once true, [`to_epsilon_delta`](Self::to_epsilon_delta)
331    /// reports an infinite epsilon until [`reset`](Self::reset) is called.
332    pub fn is_saturated(&self) -> bool {
333        self.saturated
334    }
335
336    /// Return the total number of composed mechanism applications.
337    pub fn total_steps(&self) -> usize {
338        self.total_steps
339    }
340
341    /// Return the Renyi orders tracked by this accountant.
342    pub fn orders(&self) -> &[f64] {
343        &self.orders
344    }
345}
346
347/// Compute the RDP of one application of the subsampled Gaussian mechanism
348/// at a given Renyi order.
349///
350/// For integer orders `alpha >= 2` the exact binomial expansion of the
351/// sampled-Gaussian moment generating function is evaluated in log space.
352/// The expansion is used for **every** `q > 0`: there is no small-`q`
353/// analytical shortcut, because such shortcuts under-report the privacy
354/// loss, and `ln(q)` is perfectly well behaved down to the smallest
355/// normal `f64`.
356///
357/// Non-integer orders are bounded by the value at `ceil(alpha)`. The Renyi
358/// divergence is non-decreasing in its order, so `rdp(alpha) <=
359/// rdp(ceil(alpha))`; the returned value is therefore a valid, conservative
360/// bound. Orders in `(1, 2)` are bounded by the value at `alpha = 2` for the
361/// same reason.
362///
363/// The result may be `+infinity` for pathologically small noise multipliers
364/// (the exponent `k(k-1)/(2 sigma^2)` overflows). That is reported faithfully
365/// rather than clamped: an infinite RDP means "no privacy".
366pub(crate) fn rdp_subsampled_gaussian_step(
367    alpha: f64,
368    noise_multiplier: f64,
369    q: f64,
370) -> Result<f64> {
371    if !alpha.is_finite() || alpha <= 1.0 {
372        return Err(OptimError::InvalidParameter(format!(
373            "Renyi order alpha must satisfy alpha > 1, got {alpha}"
374        )));
375    }
376    if !noise_multiplier.is_finite() || noise_multiplier <= 0.0 {
377        return Err(OptimError::InvalidParameter(format!(
378            "noise_multiplier must be a positive finite number, got {noise_multiplier}"
379        )));
380    }
381    if !q.is_finite() || !(0.0..=1.0).contains(&q) {
382        return Err(OptimError::InvalidParameter(format!(
383            "sampling probability must be in [0, 1], got {q}"
384        )));
385    }
386
387    if q == 0.0 {
388        return Ok(0.0);
389    }
390
391    if q == 1.0 {
392        // Sampling everything is equivalent to the pure Gaussian mechanism.
393        let variance = noise_multiplier * noise_multiplier;
394        return Ok(alpha / (2.0 * variance));
395    }
396
397    // Evaluate at an integer order that upper-bounds the requested one.
398    let alpha_int = if (alpha - alpha.round()).abs() < 1.0e-12 {
399        alpha.round() as usize
400    } else {
401        alpha.ceil() as usize
402    };
403    let alpha_int = alpha_int.max(2);
404
405    Ok(rdp_subsampled_gaussian_step_integer(
406        alpha_int,
407        noise_multiplier,
408        q,
409    ))
410}
411
412/// Compute the RDP per step at an integer order alpha >= 2 using the
413/// binomial expansion of the sampled-Gaussian moment generating function.
414///
415/// The bound is:
416/// ```text
417/// rdp(alpha) = (1 / (alpha - 1)) * ln( sum_{k=0..=alpha} C(alpha, k)
418///                                       * (1 - q)^(alpha - k) * q^k
419///                                       * exp( k * (k - 1) / (2 sigma^2) ) )
420/// ```
421/// Implemented in log space with the log-sum-exp trick.
422pub(crate) fn rdp_subsampled_gaussian_step_integer(alpha: usize, sigma: f64, q: f64) -> f64 {
423    if alpha < 2 {
424        // Shouldn't happen given our call sites, but be defensive.
425        return 0.0;
426    }
427
428    let alpha_f = alpha as f64;
429    let variance = sigma * sigma;
430    let log_q = q.ln();
431    let log_one_minus_q = (1.0 - q).ln();
432
433    let mut log_terms: Vec<f64> = Vec::with_capacity(alpha + 1);
434    for k in 0..=alpha {
435        let k_f = k as f64;
436        let log_binom = log_binom_coefficient(alpha_f, k);
437        let term = log_binom
438            + (alpha_f - k_f) * log_one_minus_q
439            + k_f * log_q
440            + (k_f * (k_f - 1.0)) / (2.0 * variance);
441        log_terms.push(term);
442    }
443
444    let log_sum = log_sum_exp(&log_terms);
445    let rdp = log_sum / (alpha_f - 1.0);
446
447    if rdp.is_nan() {
448        // Pathological inputs: fail closed with "no privacy" rather than
449        // propagating a NaN that compares false against every budget check.
450        f64::INFINITY
451    } else if rdp < 0.0 {
452        // RDP is non-negative by definition; a small negative value can only
453        // come from floating-point round-off in the log-sum-exp.
454        0.0
455    } else {
456        // May legitimately be +infinity for a vanishing noise multiplier.
457        rdp
458    }
459}
460
461/// Numerically stable log of the sum of exponentials of the input slice.
462fn log_sum_exp(values: &[f64]) -> f64 {
463    if values.is_empty() {
464        return f64::NEG_INFINITY;
465    }
466
467    let mut max = f64::NEG_INFINITY;
468    for &v in values {
469        if v > max {
470            max = v;
471        }
472    }
473
474    if !max.is_finite() {
475        return max;
476    }
477
478    let mut sum = 0.0;
479    for &v in values {
480        sum += (v - max).exp();
481    }
482
483    max + sum.ln()
484}
485
486/// Log of the binomial coefficient C(n, k) for real-valued n and
487/// non-negative integer k. Uses the recursion
488/// `log_binom(n, k) = sum_{i=1..=k} (log(n - i + 1) - log(i))`.
489///
490/// This avoids dependency on `lgamma`/`tgamma` and is numerically robust
491/// for the small values of k (up to ~64) used by this accountant.
492fn log_binom_coefficient(n: f64, k: usize) -> f64 {
493    if k == 0 {
494        return 0.0;
495    }
496
497    let mut accumulator = 0.0;
498    for i in 1..=k {
499        let i_f = i as f64;
500        let numerator = n - i_f + 1.0;
501        if numerator <= 0.0 {
502            // C(n, k) = 0 in this case; return a large negative log.
503            return f64::NEG_INFINITY;
504        }
505        accumulator += numerator.ln() - i_f.ln();
506    }
507    accumulator
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    const APPROX_TOL: f64 = 1.0e-9;
515
516    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
517        (a - b).abs() <= tol
518    }
519
520    #[test]
521    fn test_default_orders_includes_typical_values() {
522        let orders = RenyiAccountant::default_orders();
523        for needle in [1.25_f64, 2.0, 4.0, 16.0, 64.0] {
524            assert!(
525                orders.iter().any(|o| (o - needle).abs() < 1.0e-12),
526                "default orders must contain {needle}"
527            );
528        }
529    }
530
531    #[test]
532    fn test_new_validates_orders_above_one() {
533        let result = RenyiAccountant::new(vec![0.5_f64, 2.0]);
534        match result {
535            Err(OptimError::InvalidParameter(_)) => {}
536            other => panic!("expected InvalidParameter, got {other:?}"),
537        }
538    }
539
540    #[test]
541    fn test_new_sorts_unsorted_input() {
542        let accountant = RenyiAccountant::new(vec![4.0_f64, 2.0]).expect("should accept orders");
543        let orders = accountant.orders();
544        assert_eq!(orders.len(), 2);
545        assert!(orders[0] < orders[1]);
546        assert!(approx_eq(orders[0], 2.0, APPROX_TOL));
547        assert!(approx_eq(orders[1], 4.0, APPROX_TOL));
548    }
549
550    #[test]
551    fn test_zero_steps_zero_spend() {
552        let accountant = RenyiAccountant::with_default_orders();
553        let spend = accountant.current_spend();
554        assert_eq!(spend.orders.len(), spend.epsilons.len());
555        for eps in spend.epsilons {
556            assert_eq!(eps, 0.0);
557        }
558        assert_eq!(accountant.total_steps(), 0);
559    }
560
561    #[test]
562    fn test_spend_grows_monotonically_with_steps() {
563        let mut accountant = RenyiAccountant::with_default_orders();
564        accountant
565            .add_subsampled_gaussian(1.0, 0.01, 100)
566            .expect("first composition should succeed");
567        let first = accountant.current_spend();
568        for &eps in &first.epsilons {
569            assert!(eps >= 0.0, "RDP must be non-negative, got {eps}");
570        }
571
572        accountant
573            .add_subsampled_gaussian(1.0, 0.01, 100)
574            .expect("second composition should succeed");
575        let second = accountant.current_spend();
576
577        for (a, b) in first.epsilons.iter().zip(second.epsilons.iter()) {
578            assert!(b >= a, "RDP must grow monotonically, got {a} -> {b}");
579            if *a > 0.0 {
580                assert!(b > a, "RDP should strictly grow with more steps");
581            }
582        }
583
584        assert_eq!(accountant.total_steps(), 200);
585    }
586
587    #[test]
588    fn test_higher_noise_smaller_spend() {
589        let mut low_noise = RenyiAccountant::with_default_orders();
590        low_noise
591            .add_subsampled_gaussian(1.0, 0.01, 500)
592            .expect("low noise composition");
593        let mut high_noise = RenyiAccountant::with_default_orders();
594        high_noise
595            .add_subsampled_gaussian(2.0, 0.01, 500)
596            .expect("high noise composition");
597
598        let low = low_noise.current_spend();
599        let high = high_noise.current_spend();
600
601        for (a, b) in low.epsilons.iter().zip(high.epsilons.iter()) {
602            assert!(
603                *b <= *a + APPROX_TOL,
604                "higher noise should yield smaller RDP: low={a}, high={b}"
605            );
606        }
607    }
608
609    #[test]
610    fn test_smaller_sampling_smaller_spend() {
611        let mut sparse = RenyiAccountant::with_default_orders();
612        sparse
613            .add_subsampled_gaussian(1.0, 0.001, 500)
614            .expect("sparse sampling composition");
615        let mut dense = RenyiAccountant::with_default_orders();
616        dense
617            .add_subsampled_gaussian(1.0, 0.01, 500)
618            .expect("dense sampling composition");
619
620        let sparse_spend = sparse.current_spend();
621        let dense_spend = dense.current_spend();
622
623        for (s, d) in sparse_spend
624            .epsilons
625            .iter()
626            .zip(dense_spend.epsilons.iter())
627        {
628            assert!(
629                *s <= *d + APPROX_TOL,
630                "smaller sampling probability should yield smaller RDP: sparse={s}, dense={d}"
631            );
632        }
633    }
634
635    #[test]
636    fn test_pure_gaussian_matches_analytical_formula() {
637        // For pure Gaussian sigma=1, the RDP at alpha=2 should be exactly
638        // alpha / (2 sigma^2) = 1.0.
639        let mut accountant = RenyiAccountant::new(vec![2.0_f64]).expect("alpha=2 is valid");
640        accountant.add_gaussian(1.0, 1).expect("gaussian step");
641
642        let spend = accountant.current_spend();
643        assert_eq!(spend.orders.len(), 1);
644        assert!(
645            approx_eq(spend.epsilons[0], 1.0, 1.0e-12),
646            "expected exactly 1.0, got {}",
647            spend.epsilons[0]
648        );
649
650        // After 5 steps, RDP at alpha=2 should be 5.0.
651        accountant
652            .add_gaussian(1.0, 4)
653            .expect("more gaussian steps");
654        let spend = accountant.current_spend();
655        assert!(
656            approx_eq(spend.epsilons[0], 5.0, 1.0e-12),
657            "expected 5.0, got {}",
658            spend.epsilons[0]
659        );
660    }
661
662    #[test]
663    fn test_to_epsilon_delta_returns_finite_when_spend_nonzero() {
664        let mut accountant = RenyiAccountant::with_default_orders();
665        accountant
666            .add_subsampled_gaussian(1.0, 0.01, 1000)
667            .expect("composition");
668
669        let result = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
670        assert!(result.epsilon.is_finite());
671        assert!(result.epsilon > 0.0);
672        assert!(approx_eq(result.delta, 1.0e-5, 1.0e-18));
673        let orders = accountant.orders();
674        assert!(orders
675            .iter()
676            .any(|o| approx_eq(*o, result.best_order, APPROX_TOL)));
677    }
678
679    #[test]
680    fn test_to_epsilon_delta_invalid_target_delta_errors() {
681        let mut accountant = RenyiAccountant::with_default_orders();
682        accountant.add_gaussian(1.0, 1).expect("step");
683
684        match accountant.to_epsilon_delta(0.0) {
685            Err(OptimError::InvalidParameter(_)) => {}
686            other => panic!("expected InvalidParameter for delta=0, got {other:?}"),
687        }
688
689        match accountant.to_epsilon_delta(2.0) {
690            Err(OptimError::InvalidParameter(_)) => {}
691            other => panic!("expected InvalidParameter for delta>1, got {other:?}"),
692        }
693
694        match accountant.to_epsilon_delta(-0.1) {
695            Err(OptimError::InvalidParameter(_)) => {}
696            other => panic!("expected InvalidParameter for negative delta, got {other:?}"),
697        }
698    }
699
700    #[test]
701    fn test_to_epsilon_delta_chooses_optimal_order() {
702        let mut accountant = RenyiAccountant::with_default_orders();
703        accountant
704            .add_subsampled_gaussian(1.1, 0.005, 500)
705            .expect("composition");
706        let result = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
707
708        let orders = accountant.orders();
709        assert!(
710            orders
711                .iter()
712                .any(|o| approx_eq(*o, result.best_order, APPROX_TOL)),
713            "best_order {} must come from configured order list",
714            result.best_order
715        );
716    }
717
718    #[test]
719    fn test_composition_linear_in_steps() {
720        // Doing 1000 steps in one call should produce the same per-order RDP
721        // as ten calls with 100 steps each, up to floating point noise.
722        let mut single = RenyiAccountant::with_default_orders();
723        single
724            .add_subsampled_gaussian(1.0, 0.01, 1000)
725            .expect("single composition");
726
727        let mut chunked = RenyiAccountant::with_default_orders();
728        for _ in 0..10 {
729            chunked
730                .add_subsampled_gaussian(1.0, 0.01, 100)
731                .expect("chunked composition");
732        }
733
734        let s = single.current_spend();
735        let c = chunked.current_spend();
736        assert_eq!(s.orders.len(), c.orders.len());
737        for (a, b) in s.epsilons.iter().zip(c.epsilons.iter()) {
738            assert!(
739                approx_eq(*a, *b, 1.0e-9),
740                "composition must be linear in steps: {a} vs {b}"
741            );
742        }
743
744        assert_eq!(single.total_steps(), 1000);
745        assert_eq!(chunked.total_steps(), 1000);
746    }
747
748    #[test]
749    fn test_reset_clears_spend() {
750        let mut accountant = RenyiAccountant::with_default_orders();
751        accountant
752            .add_subsampled_gaussian(1.0, 0.01, 500)
753            .expect("composition");
754        assert!(accountant.total_steps() > 0);
755
756        accountant.reset();
757        assert_eq!(accountant.total_steps(), 0);
758        let spend = accountant.current_spend();
759        for eps in spend.epsilons {
760            assert_eq!(eps, 0.0);
761        }
762    }
763
764    #[test]
765    fn test_serde_roundtrip_rdpspend() {
766        let mut accountant = RenyiAccountant::with_default_orders();
767        accountant
768            .add_subsampled_gaussian(1.2, 0.005, 200)
769            .expect("composition");
770        let spend = accountant.current_spend();
771
772        let json = serde_json::to_string(&spend).expect("serialize");
773        let parsed: RdpSpend = serde_json::from_str(&json).expect("deserialize");
774        assert_eq!(parsed.orders.len(), spend.orders.len());
775        for (a, b) in parsed.orders.iter().zip(spend.orders.iter()) {
776            assert!(approx_eq(*a, *b, APPROX_TOL));
777        }
778        for (a, b) in parsed.epsilons.iter().zip(spend.epsilons.iter()) {
779            assert!(approx_eq(*a, *b, APPROX_TOL));
780        }
781
782        // DpConversion roundtrip as well, since it is also serde-derived.
783        let conv = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
784        let conv_json = serde_json::to_string(&conv).expect("serialize conversion");
785        let parsed_conv: DpConversion =
786            serde_json::from_str(&conv_json).expect("deserialize conversion");
787        assert!(approx_eq(parsed_conv.epsilon, conv.epsilon, APPROX_TOL));
788        assert!(approx_eq(parsed_conv.delta, conv.delta, APPROX_TOL));
789        assert!(approx_eq(
790            parsed_conv.best_order,
791            conv.best_order,
792            APPROX_TOL
793        ));
794    }
795
796    #[test]
797    fn test_negative_noise_multiplier_errors() {
798        let mut accountant = RenyiAccountant::with_default_orders();
799        match accountant.add_subsampled_gaussian(-1.0, 0.01, 100) {
800            Err(OptimError::InvalidParameter(_)) => {}
801            other => panic!("expected InvalidParameter for negative noise, got {other:?}"),
802        }
803        match accountant.add_gaussian(-1.0, 100) {
804            Err(OptimError::InvalidParameter(_)) => {}
805            other => panic!("expected InvalidParameter for negative noise, got {other:?}"),
806        }
807        match accountant.add_subsampled_gaussian(0.0, 0.01, 100) {
808            Err(OptimError::InvalidParameter(_)) => {}
809            other => panic!("expected InvalidParameter for zero noise, got {other:?}"),
810        }
811    }
812
813    #[test]
814    fn test_invalid_sampling_prob_errors() {
815        let mut accountant = RenyiAccountant::with_default_orders();
816        match accountant.add_subsampled_gaussian(1.0, -0.1, 100) {
817            Err(OptimError::InvalidParameter(_)) => {}
818            other => panic!("expected InvalidParameter for negative q, got {other:?}"),
819        }
820        match accountant.add_subsampled_gaussian(1.0, 1.5, 100) {
821            Err(OptimError::InvalidParameter(_)) => {}
822            other => panic!("expected InvalidParameter for q > 1, got {other:?}"),
823        }
824    }
825
826    #[test]
827    fn test_zero_sampling_prob_zero_spend() {
828        let mut accountant = RenyiAccountant::with_default_orders();
829        accountant
830            .add_subsampled_gaussian(1.0, 0.0, 1000)
831            .expect("zero-q composition should succeed");
832        let spend = accountant.current_spend();
833        for eps in spend.epsilons {
834            assert_eq!(eps, 0.0, "zero sampling probability must yield zero RDP");
835        }
836        assert_eq!(accountant.total_steps(), 1000);
837    }
838
839    #[test]
840    fn test_canonical_dp_sgd_setup() {
841        // Standard DP-SGD config: sigma=1.0, q=0.01, 1000 steps, delta=1e-5.
842        // The resulting epsilon should be a reasonable single-digit value.
843        let mut accountant = RenyiAccountant::with_default_orders();
844        accountant
845            .add_subsampled_gaussian(1.0, 0.01, 1000)
846            .expect("dp-sgd composition");
847
848        let result = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
849        assert!(result.epsilon.is_finite());
850        assert!(
851            result.epsilon >= 0.5 && result.epsilon <= 10.0,
852            "expected epsilon in [0.5, 10] for canonical setup, got {}",
853            result.epsilon
854        );
855    }
856
857    #[test]
858    fn test_kernel_reproduces_the_published_tensorflow_privacy_reference() {
859        // EXTERNAL validation of the sampled-Gaussian RDP kernel against a
860        // number published by an independent implementation.
861        //
862        // The TensorFlow Privacy classification tutorial reports, for
863        // `N = 60000, batch_size = 250, noise_multiplier = 1.3, epochs = 15`
864        // (so `q = 250 / 60000` and `T = 15 * 60000 / 250 = 3600`) at
865        // `delta = 1e-5`:
866        //
867        // > DP-SGD with sampling rate = 0.417% and noise_multiplier = 1.3
868        // > iterated over 3600 steps satisfies differential privacy with
869        // > eps = 1.18.
870        //
871        // TF Privacy's `get_privacy_spent` applies the *classic* Mironov
872        // (2017) conversion `rdp + ln(1/delta) / (alpha - 1)`, so that
873        // conversion is applied here explicitly instead of calling
874        // `to_epsilon_delta` (which uses the strictly tighter Canonne-Kamath-
875        // Steinke bound and would land at 0.9422, below the published value).
876        //
877        // Matching 1.18 to four significant figures is what makes every other
878        // pinned constant in this module a real golden value rather than a
879        // restatement of our own arithmetic.
880        let orders: Vec<f64> = (2..=64).map(f64::from).collect();
881        let mut accountant = RenyiAccountant::new(orders).expect("integer orders are valid");
882        accountant
883            .add_subsampled_gaussian(1.3, 250.0 / 60_000.0, 3600)
884            .expect("composition");
885
886        let spend = accountant.current_spend();
887        let log_inv_delta = (1.0_f64 / 1.0e-5).ln();
888        let mut best = f64::INFINITY;
889        let mut best_order = 0.0;
890        for (order, rdp) in spend.orders.iter().zip(spend.epsilons.iter()) {
891            let candidate = rdp + log_inv_delta / (order - 1.0);
892            if candidate < best {
893                best = candidate;
894                best_order = *order;
895            }
896        }
897
898        assert!(
899            approx_eq(best, 1.179_900_673_983, 1.0e-9),
900            "classic-conversion epsilon must reproduce the published TF Privacy \
901             value 1.18, got {best} at alpha={best_order}"
902        );
903        assert_eq!(best_order, 17.0, "TF Privacy also selects alpha = 17");
904
905        // The crate's own (tighter) conversion must sit strictly below the
906        // classic one and therefore remain a valid guarantee.
907        let tight = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
908        assert!(
909            tight.epsilon < best,
910            "CKS conversion must be tighter than the classic one: {} vs {best}",
911            tight.epsilon
912        );
913    }
914
915    #[test]
916    fn test_per_step_rdp_matches_quadrature_validated_golden_values() {
917        // Golden per-step RDP values for the sampled Gaussian mechanism.
918        //
919        // Provenance: each value was cross-checked against a direct Simpson
920        // quadrature of the Renyi divergence integral
921        //
922        //     exp((alpha - 1) * rdp)
923        //       = E_{x ~ N(0, sigma^2)} [ ((1 - q) + q e^{(2x - 1)/(2 sigma^2)})^alpha ]
924        //
925        // which shares no code path with the binomial expansion implemented
926        // here; agreement was better than 1e-12 relative in every case.
927        let cases: [(f64, f64, f64, f64); 5] = [
928            (2.0, 1.0, 0.01, 1.718_134_220_745_140_6e-4),
929            (8.0, 1.0, 0.01, 8.936_439_076_060_275e-4),
930            (16.0, 1.0, 0.01, 3.087_850_783_696_245),
931            (12.0, 1.1, 256.0 / 60_000.0, 1.557_401_620_924_204_6e-4),
932            (24.0, 2.0, 0.01, 3.663_592_275_686_629e-4),
933        ];
934
935        for (alpha, sigma, q, expected) in cases {
936            let actual = rdp_subsampled_gaussian_step(alpha, sigma, q).expect("valid parameters");
937            let relative = (actual - expected).abs() / expected;
938            assert!(
939                relative < 1.0e-12,
940                "rdp(alpha={alpha}, sigma={sigma}, q={q}) = {actual}, expected {expected} \
941                 (relative error {relative:e})"
942            );
943        }
944    }
945
946    #[test]
947    fn test_kernel_converges_to_the_pure_gaussian_closed_form() {
948        // As q -> 1 the sampled Gaussian *is* the Gaussian mechanism, whose
949        // RDP has the closed form alpha / (2 sigma^2) (Mironov 2017,
950        // Proposition 7). The binomial expansion must converge to it, which
951        // pins the kernel against a formula it does not share any code with.
952        for sigma in [0.5_f64, 1.0, 1.1, 2.0] {
953            for alpha in [2.0_f64, 4.0, 8.0, 16.0, 32.0] {
954                let closed_form = alpha / (2.0 * sigma * sigma);
955                let expansion =
956                    rdp_subsampled_gaussian_step(alpha, sigma, 1.0 - 1.0e-10).expect("valid");
957                let relative = (expansion - closed_form).abs() / closed_form;
958                assert!(
959                    relative < 1.0e-8,
960                    "expansion {expansion} must converge to {closed_form} \
961                     (sigma={sigma}, alpha={alpha}, relative error {relative:e})"
962                );
963
964                // Exactly q = 1 takes the closed-form branch.
965                let exact = rdp_subsampled_gaussian_step(alpha, sigma, 1.0).expect("valid");
966                assert!(approx_eq(exact, closed_form, 1.0e-12));
967            }
968        }
969    }
970
971    #[test]
972    fn test_golden_epsilon_for_the_canonical_dp_sgd_configuration() {
973        // Pinned epsilon for sigma = 1.0, q = 0.01, delta = 1e-5 over the
974        // canonical [`DEFAULT_ALPHAS`] grid with the CKS conversion.
975        //
976        // These constants pin *this crate's* configuration (integer-order
977        // bound with `ceil` for fractional alphas, DEFAULT_ALPHAS grid, CKS
978        // conversion). They are not published Opacus/TF-Privacy outputs -- the
979        // external anchor is
980        // `test_kernel_reproduces_the_published_tensorflow_privacy_reference`,
981        // which validates the underlying kernel; these values then follow from
982        // it by composition and conversion.
983        let expected: [(usize, f64, f64); 4] = [
984            (1, 0.956_281_055_679, 10.0),
985            (10, 1.064_496_195_732, 9.0),
986            (100, 1.224_845_779_636, 9.0),
987            (1000, 2.107_753_075_452, 8.0),
988        ];
989
990        for (steps, epsilon, order) in expected {
991            let mut accountant = RenyiAccountant::with_default_orders();
992            accountant
993                .add_subsampled_gaussian(1.0, 0.01, steps)
994                .expect("composition");
995            let result = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
996            assert!(
997                approx_eq(result.epsilon, epsilon, 1.0e-9),
998                "T={steps}: epsilon {} must equal the golden value {epsilon}",
999                result.epsilon
1000            );
1001            assert_eq!(
1002                result.best_order, order,
1003                "T={steps}: optimal Renyi order changed"
1004            );
1005        }
1006    }
1007
1008    #[test]
1009    fn test_small_q_uses_exact_expansion_not_a_shortcut() {
1010        // Regression for the deleted "small q" analytical shortcut, which
1011        // returned q^2 * alpha / (2 sigma^2) below q = 1e-6 and under-reported
1012        // the true RDP by orders of magnitude. The exact expansion must be
1013        // continuous across the old threshold and must dominate the shortcut.
1014        let sigma = 1.0_f64;
1015        let alpha = 8.0_f64;
1016        for &q in &[9.0e-7_f64, 1.0e-6, 1.1e-6] {
1017            let exact = rdp_subsampled_gaussian_step(alpha, sigma, q).expect("valid parameters");
1018            let old_shortcut = q * q * alpha / (2.0 * sigma * sigma);
1019            assert!(
1020                exact >= old_shortcut,
1021                "exact bound {exact} must not fall below the discarded shortcut {old_shortcut}"
1022            );
1023            assert!(exact.is_finite() && exact > 0.0);
1024        }
1025
1026        // Continuity across the old threshold: relative change is tiny.
1027        let below = rdp_subsampled_gaussian_step(alpha, sigma, 9.99e-7).expect("valid");
1028        let above = rdp_subsampled_gaussian_step(alpha, sigma, 1.01e-6).expect("valid");
1029        assert!(
1030            (above - below).abs() / above < 0.05,
1031            "kernel must be continuous across the removed threshold: {below} vs {above}"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_fractional_orders_are_conservative_upper_bounds() {
1037        // RDP is non-decreasing in the order, so the value reported for a
1038        // fractional alpha must sit at or above the value at floor(alpha)
1039        // and equal the value at ceil(alpha).
1040        let sigma = 1.0_f64;
1041        let q = 0.01_f64;
1042
1043        let at_two = rdp_subsampled_gaussian_step(2.0, sigma, q).expect("valid");
1044        let at_three = rdp_subsampled_gaussian_step(3.0, sigma, q).expect("valid");
1045        let at_two_five = rdp_subsampled_gaussian_step(2.5, sigma, q).expect("valid");
1046        assert!(at_two_five >= at_two);
1047        assert!(approx_eq(at_two_five, at_three, 1.0e-12));
1048
1049        // Orders in (1, 2) previously extrapolated with a negative weight,
1050        // producing values below the true RDP. They must now be bounded by
1051        // the alpha = 2 value.
1052        for &alpha in &[1.25_f64, 1.5, 1.75] {
1053            let value = rdp_subsampled_gaussian_step(alpha, sigma, q).expect("valid");
1054            assert!(value > 0.0, "order {alpha} must have positive RDP");
1055            assert!(
1056                approx_eq(value, at_two, 1.0e-12),
1057                "order {alpha} must be bounded by the alpha=2 value"
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn test_tiny_noise_multiplier_reports_infinite_epsilon() {
1064        // Previously a sigma below 0.5 silently returned a capped constant.
1065        // The accountant must instead report saturation and an infinite
1066        // epsilon: fail closed, never a fabricated finite budget.
1067        let mut accountant = RenyiAccountant::with_default_orders();
1068        accountant
1069            .add_subsampled_gaussian(1.0e-8, 0.5, 1000)
1070            .expect("composition should be accepted");
1071        assert!(accountant.is_saturated());
1072
1073        let conversion = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
1074        assert!(
1075            conversion.epsilon.is_infinite(),
1076            "saturated accountant must report infinite epsilon, got {}",
1077            conversion.epsilon
1078        );
1079
1080        accountant.reset();
1081        assert!(!accountant.is_saturated());
1082    }
1083
1084    #[test]
1085    fn test_moderately_small_sigma_still_computed_exactly() {
1086        // sigma = 0.4 used to hit the MIN_SAFE_SIGMA shortcut; the log-space
1087        // routine handles it exactly and must produce a finite bound.
1088        let mut accountant = RenyiAccountant::with_default_orders();
1089        accountant
1090            .add_subsampled_gaussian(0.4, 0.01, 100)
1091            .expect("composition");
1092        assert!(!accountant.is_saturated());
1093        let conversion = accountant.to_epsilon_delta(1.0e-5).expect("conversion");
1094        assert!(conversion.epsilon.is_finite() && conversion.epsilon > 0.0);
1095    }
1096
1097    #[test]
1098    fn test_cks_conversion_is_tighter_than_classic() {
1099        let mut accountant = RenyiAccountant::with_default_orders();
1100        accountant
1101            .add_subsampled_gaussian(1.0, 0.01, 1000)
1102            .expect("composition");
1103        let delta = 1.0e-5_f64;
1104        let converted = accountant.to_epsilon_delta(delta).expect("conversion");
1105
1106        // Classic Mironov conversion over the same spend.
1107        let spend = accountant.current_spend();
1108        let log_inv_delta = (1.0 / delta).ln();
1109        let mut classic = f64::INFINITY;
1110        for (i, &alpha) in spend.orders.iter().enumerate() {
1111            let candidate = spend.epsilons[i] + log_inv_delta / (alpha - 1.0);
1112            if candidate < classic {
1113                classic = candidate;
1114            }
1115        }
1116
1117        assert!(
1118            converted.epsilon <= classic + 1.0e-12,
1119            "CKS conversion {} must not exceed the classic bound {classic}",
1120            converted.epsilon
1121        );
1122        assert!(converted.epsilon > 0.0);
1123    }
1124
1125    #[test]
1126    fn test_log_sum_exp_handles_extreme_inputs() {
1127        // Internal helper coverage to confirm numerical stability.
1128        let values = [1.0e6_f64, 1.0e6 + 1.0, 1.0e6 + 2.0];
1129        let result = log_sum_exp(&values);
1130        assert!(result.is_finite());
1131        // The result should be roughly max + ln(1 + e + e^2).
1132        let expected = 1.0e6 + (1.0_f64 + std::f64::consts::E + std::f64::consts::E.powi(2)).ln();
1133        assert!(approx_eq(result, expected, 1.0e-6));
1134
1135        // Empty input is well-defined as -infinity.
1136        let empty: [f64; 0] = [];
1137        assert!(log_sum_exp(&empty).is_infinite());
1138    }
1139
1140    #[test]
1141    fn test_log_binom_known_values() {
1142        // C(10, 0) = 1 -> log = 0
1143        assert!(approx_eq(log_binom_coefficient(10.0, 0), 0.0, 1.0e-12));
1144        // C(10, 1) = 10 -> log = ln(10)
1145        assert!(approx_eq(
1146            log_binom_coefficient(10.0, 1),
1147            10.0_f64.ln(),
1148            1.0e-12
1149        ));
1150        // C(5, 2) = 10 -> log = ln(10)
1151        assert!(approx_eq(
1152            log_binom_coefficient(5.0, 2),
1153            10.0_f64.ln(),
1154            1.0e-12
1155        ));
1156        // C(8, 4) = 70 -> log = ln(70)
1157        assert!(approx_eq(
1158            log_binom_coefficient(8.0, 4),
1159            70.0_f64.ln(),
1160            1.0e-12
1161        ));
1162    }
1163}