1use crate::error::{OptimError, Result};
33use serde::{Deserialize, Serialize};
34
35pub 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
45const RDP_SATURATION_THRESHOLD: f64 = 1.0e12;
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RdpSpend {
59 pub orders: Vec<f64>,
61
62 pub epsilons: Vec<f64>,
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
68pub struct DpConversion {
69 pub epsilon: f64,
71
72 pub delta: f64,
74
75 pub best_order: f64,
77}
78
79#[derive(Debug, Clone)]
88pub struct RenyiAccountant {
89 orders: Vec<f64>,
91
92 rdp_epsilons: Vec<f64>,
94
95 total_steps: usize,
97
98 saturated: bool,
102}
103
104impl RenyiAccountant {
105 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 pub fn with_default_orders() -> Self {
144 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 pub fn default_orders() -> Vec<f64> {
158 DEFAULT_ALPHAS.to_vec()
159 }
160
161 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 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 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 pub fn current_spend(&self) -> RdpSpend {
242 RdpSpend {
243 orders: self.orders.clone(),
244 epsilons: self.rdp_epsilons.clone(),
245 }
246 }
247
248 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 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 let epsilon = best_epsilon.max(0.0);
312
313 Ok(DpConversion {
314 epsilon,
315 delta: target_delta,
316 best_order,
317 })
318 }
319
320 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 pub fn is_saturated(&self) -> bool {
333 self.saturated
334 }
335
336 pub fn total_steps(&self) -> usize {
338 self.total_steps
339 }
340
341 pub fn orders(&self) -> &[f64] {
343 &self.orders
344 }
345}
346
347pub(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 let variance = noise_multiplier * noise_multiplier;
394 return Ok(alpha / (2.0 * variance));
395 }
396
397 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
412pub(crate) fn rdp_subsampled_gaussian_step_integer(alpha: usize, sigma: f64, q: f64) -> f64 {
423 if alpha < 2 {
424 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 f64::INFINITY
451 } else if rdp < 0.0 {
452 0.0
455 } else {
456 rdp
458 }
459}
460
461fn 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
486fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let empty: [f64; 0] = [];
1137 assert!(log_sum_exp(&empty).is_infinite());
1138 }
1139
1140 #[test]
1141 fn test_log_binom_known_values() {
1142 assert!(approx_eq(log_binom_coefficient(10.0, 0), 0.0, 1.0e-12));
1144 assert!(approx_eq(
1146 log_binom_coefficient(10.0, 1),
1147 10.0_f64.ln(),
1148 1.0e-12
1149 ));
1150 assert!(approx_eq(
1152 log_binom_coefficient(5.0, 2),
1153 10.0_f64.ln(),
1154 1.0e-12
1155 ));
1156 assert!(approx_eq(
1158 log_binom_coefficient(8.0, 4),
1159 70.0_f64.ln(),
1160 1.0e-12
1161 ));
1162 }
1163}