use std::fmt::Debug;
use super::moment_accountant::MomentsAccountant;
use super::renyi_accountant::RenyiAccountant;
use super::AccountingMethod;
use crate::error::{OptimError, Result};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccountingSegment {
pub noise_multiplier: f64,
pub sampling_probability: f64,
pub steps: usize,
}
#[derive(Debug, Clone, Default)]
pub struct PrivacyLedger {
segments: Vec<AccountingSegment>,
}
impl PrivacyLedger {
pub fn new() -> Self {
Self {
segments: Vec::new(),
}
}
pub fn record(
&mut self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
) -> Result<()> {
if !noise_multiplier.is_finite() || noise_multiplier <= 0.0 {
return Err(OptimError::InvalidParameter(format!(
"noise_multiplier must be a positive finite number, got {noise_multiplier}"
)));
}
if !sampling_probability.is_finite() || !(0.0..=1.0).contains(&sampling_probability) {
return Err(OptimError::InvalidParameter(format!(
"sampling_probability must be in [0, 1], got {sampling_probability}"
)));
}
if steps == 0 {
return Ok(());
}
if let Some(last) = self.segments.last_mut() {
if last.noise_multiplier == noise_multiplier
&& last.sampling_probability == sampling_probability
{
last.steps = last.steps.saturating_add(steps);
return Ok(());
}
}
self.segments.push(AccountingSegment {
noise_multiplier,
sampling_probability,
steps,
});
Ok(())
}
pub fn segments(&self) -> &[AccountingSegment] {
&self.segments
}
pub fn total_steps(&self) -> usize {
self.segments
.iter()
.fold(0usize, |acc, s| acc.saturating_add(s.steps))
}
pub fn clear(&mut self) {
self.segments.clear();
}
}
pub trait PrivacyAccountant: Debug + Send {
fn compose_subsampled_gaussian(
&mut self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
) -> Result<()>;
fn privacy_spent(&self, target_delta: f64) -> Result<(f64, f64)>;
fn projected_privacy_spent(
&self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
target_delta: f64,
) -> Result<(f64, f64)>;
fn total_steps(&self) -> usize;
fn segments(&self) -> &[AccountingSegment];
fn method(&self) -> AccountingMethod;
fn reset(&mut self);
}
#[derive(Debug, Clone)]
pub struct RenyiPrivacyAccountant {
inner: RenyiAccountant,
ledger: PrivacyLedger,
}
impl RenyiPrivacyAccountant {
pub fn new() -> Self {
Self {
inner: RenyiAccountant::with_default_orders(),
ledger: PrivacyLedger::new(),
}
}
pub fn with_orders(orders: Vec<f64>) -> Result<Self> {
Ok(Self {
inner: RenyiAccountant::new(orders)?,
ledger: PrivacyLedger::new(),
})
}
pub fn inner(&self) -> &RenyiAccountant {
&self.inner
}
}
impl Default for RenyiPrivacyAccountant {
fn default() -> Self {
Self::new()
}
}
impl PrivacyAccountant for RenyiPrivacyAccountant {
fn compose_subsampled_gaussian(
&mut self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
) -> Result<()> {
self.ledger
.record(noise_multiplier, sampling_probability, steps)?;
self.inner
.add_subsampled_gaussian(noise_multiplier, sampling_probability, steps)
}
fn privacy_spent(&self, target_delta: f64) -> Result<(f64, f64)> {
let conversion = self.inner.to_epsilon_delta(target_delta)?;
Ok((conversion.epsilon, conversion.delta))
}
fn projected_privacy_spent(
&self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
target_delta: f64,
) -> Result<(f64, f64)> {
let mut probe = self.inner.clone();
probe.add_subsampled_gaussian(noise_multiplier, sampling_probability, steps)?;
let conversion = probe.to_epsilon_delta(target_delta)?;
Ok((conversion.epsilon, conversion.delta))
}
fn total_steps(&self) -> usize {
self.ledger.total_steps()
}
fn segments(&self) -> &[AccountingSegment] {
self.ledger.segments()
}
fn method(&self) -> AccountingMethod {
AccountingMethod::RenyiDP
}
fn reset(&mut self) {
self.inner.reset();
self.ledger.clear();
}
}
#[derive(Debug, Clone)]
pub struct MomentsPrivacyAccountant {
inner: MomentsAccountant,
ledger: PrivacyLedger,
}
impl MomentsPrivacyAccountant {
pub fn new(
noise_multiplier: f64,
target_delta: f64,
batch_size: usize,
dataset_size: usize,
) -> Self {
Self {
inner: MomentsAccountant::new(noise_multiplier, target_delta, batch_size, dataset_size),
ledger: PrivacyLedger::new(),
}
}
pub fn inner(&self) -> &MomentsAccountant {
&self.inner
}
}
impl PrivacyAccountant for MomentsPrivacyAccountant {
fn compose_subsampled_gaussian(
&mut self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
) -> Result<()> {
self.ledger
.record(noise_multiplier, sampling_probability, steps)
}
fn privacy_spent(&self, target_delta: f64) -> Result<(f64, f64)> {
let epsilon = self
.inner
.compose_segments(self.ledger.segments(), target_delta)?;
Ok((epsilon, target_delta))
}
fn projected_privacy_spent(
&self,
noise_multiplier: f64,
sampling_probability: f64,
steps: usize,
target_delta: f64,
) -> Result<(f64, f64)> {
let mut probe = self.ledger.clone();
probe.record(noise_multiplier, sampling_probability, steps)?;
let epsilon = self
.inner
.compose_segments(probe.segments(), target_delta)?;
Ok((epsilon, target_delta))
}
fn total_steps(&self) -> usize {
self.ledger.total_steps()
}
fn segments(&self) -> &[AccountingSegment] {
self.ledger.segments()
}
fn method(&self) -> AccountingMethod {
AccountingMethod::MomentsAccountant
}
fn reset(&mut self) {
self.ledger.clear();
}
}
pub fn build_accountant(
method: AccountingMethod,
noise_multiplier: f64,
target_delta: f64,
batch_size: usize,
dataset_size: usize,
) -> Result<Box<dyn PrivacyAccountant>> {
match method {
AccountingMethod::RenyiDP => Ok(Box::new(RenyiPrivacyAccountant::new())),
AccountingMethod::MomentsAccountant => Ok(Box::new(MomentsPrivacyAccountant::new(
noise_multiplier,
target_delta,
batch_size,
dataset_size,
))),
AccountingMethod::AdvancedComposition => Err(OptimError::InvalidConfig(
"AccountingMethod::AdvancedComposition is not implemented for the subsampled \
Gaussian mechanism; use AccountingMethod::RenyiDP or \
AccountingMethod::MomentsAccountant"
.to_string(),
)),
AccountingMethod::ZCDP => Err(OptimError::InvalidConfig(
"AccountingMethod::ZCDP is not implemented for the subsampled Gaussian mechanism; \
use AccountingMethod::RenyiDP or AccountingMethod::MomentsAccountant"
.to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ledger_merges_identical_consecutive_segments() {
let mut ledger = PrivacyLedger::new();
ledger.record(1.1, 0.01, 10).expect("valid");
ledger.record(1.1, 0.01, 5).expect("valid");
assert_eq!(ledger.segments().len(), 1);
assert_eq!(ledger.segments()[0].steps, 15);
assert_eq!(ledger.total_steps(), 15);
}
#[test]
fn test_ledger_opens_new_segment_on_parameter_change() {
let mut ledger = PrivacyLedger::new();
ledger.record(1.1, 0.01, 10).expect("valid");
ledger.record(1.1, 0.02, 10).expect("valid");
ledger.record(2.0, 0.02, 10).expect("valid");
assert_eq!(ledger.segments().len(), 3);
assert_eq!(ledger.total_steps(), 30);
}
#[test]
fn test_ledger_rejects_invalid_parameters() {
let mut ledger = PrivacyLedger::new();
assert!(ledger.record(0.0, 0.01, 1).is_err());
assert!(ledger.record(f64::NAN, 0.01, 1).is_err());
assert!(ledger.record(1.0, 1.5, 1).is_err());
assert!(ledger.record(1.0, -0.1, 1).is_err());
assert!(ledger.segments().is_empty());
}
#[test]
fn test_renyi_accountant_epsilon_grows_with_steps() {
let mut accountant = RenyiPrivacyAccountant::new();
accountant
.compose_subsampled_gaussian(1.0, 0.01, 100)
.expect("composition");
let (eps_100, delta) = accountant.privacy_spent(1.0e-5).expect("conversion");
assert_eq!(delta, 1.0e-5);
accountant
.compose_subsampled_gaussian(1.0, 0.01, 100)
.expect("composition");
let (eps_200, _) = accountant.privacy_spent(1.0e-5).expect("conversion");
assert!(eps_200 > eps_100, "{eps_200} must exceed {eps_100}");
assert_eq!(accountant.total_steps(), 200);
assert_eq!(accountant.segments().len(), 1);
}
#[test]
fn test_delta_is_a_reporting_parameter_not_a_spend() {
let mut accountant = RenyiPrivacyAccountant::new();
accountant
.compose_subsampled_gaussian(1.0, 0.01, 500)
.expect("composition");
let (eps_loose, _) = accountant.privacy_spent(1.0e-4).expect("conversion");
let (eps_tight, _) = accountant.privacy_spent(1.0e-7).expect("conversion");
assert!(
eps_tight > eps_loose,
"smaller delta must imply larger epsilon: {eps_tight} vs {eps_loose}"
);
}
#[test]
fn test_moments_and_renyi_agree_within_an_order_of_magnitude() {
let mut renyi = RenyiPrivacyAccountant::new();
let mut moments = MomentsPrivacyAccountant::new(1.0, 1.0e-5, 100, 10_000);
renyi
.compose_subsampled_gaussian(1.0, 0.01, 1000)
.expect("composition");
moments
.compose_subsampled_gaussian(1.0, 0.01, 1000)
.expect("composition");
let (eps_renyi, _) = renyi.privacy_spent(1.0e-5).expect("conversion");
let (eps_moments, _) = moments.privacy_spent(1.0e-5).expect("conversion");
assert!(eps_renyi.is_finite() && eps_renyi > 0.0);
assert!(eps_moments.is_finite() && eps_moments > 0.0);
let ratio = eps_moments / eps_renyi;
assert!(
(0.5..=2.0).contains(&ratio),
"the two accountants compose the same kernel and must agree closely: \
renyi={eps_renyi}, moments={eps_moments}"
);
}
#[test]
fn test_golden_epsilon_for_both_accounting_methods() {
let cases: [(usize, f64, f64); 2] = [
(100, 1.224_845_779_636, 1.617_281_887_460),
(1000, 2.107_753_075_452, 2.538_347_545_459),
];
for (steps, golden_renyi, golden_moments) in cases {
let mut renyi = RenyiPrivacyAccountant::new();
let mut moments = MomentsPrivacyAccountant::new(1.0, 1.0e-5, 100, 10_000);
renyi
.compose_subsampled_gaussian(1.0, 0.01, steps)
.expect("composition");
moments
.compose_subsampled_gaussian(1.0, 0.01, steps)
.expect("composition");
let (eps_renyi, _) = renyi.privacy_spent(1.0e-5).expect("conversion");
let (eps_moments, _) = moments.privacy_spent(1.0e-5).expect("conversion");
assert!(
(eps_renyi - golden_renyi).abs() < 1.0e-9,
"T={steps}: Renyi epsilon {eps_renyi} != golden {golden_renyi}"
);
assert!(
(eps_moments - golden_moments).abs() < 1.0e-9,
"T={steps}: moments epsilon {eps_moments} != golden {golden_moments}"
);
assert!(
eps_moments > eps_renyi,
"T={steps}: the classic conversion cannot be tighter than CKS"
);
}
}
#[test]
fn test_projected_spend_matches_the_spend_after_actually_composing() {
for method in [
AccountingMethod::RenyiDP,
AccountingMethod::MomentsAccountant,
] {
let mut accountant =
build_accountant(method, 1.0, 1.0e-5, 100, 10_000).expect("implemented method");
accountant
.compose_subsampled_gaussian(1.0, 0.01, 99)
.expect("composition");
let (projected, _) = accountant
.projected_privacy_spent(1.0, 0.01, 1, 1.0e-5)
.expect("projection");
assert_eq!(
accountant.total_steps(),
99,
"{method:?}: a projection must not record anything"
);
accountant
.compose_subsampled_gaussian(1.0, 0.01, 1)
.expect("composition");
let (actual, _) = accountant.privacy_spent(1.0e-5).expect("conversion");
assert!(
(projected - actual).abs() < 1.0e-12,
"{method:?}: projected {projected} must equal realised {actual}"
);
}
}
#[test]
fn test_heterogeneous_segments_compose() {
let mut accountant = RenyiPrivacyAccountant::new();
accountant
.compose_subsampled_gaussian(1.0, 0.01, 100)
.expect("segment 1");
let (eps_after_first, _) = accountant.privacy_spent(1.0e-5).expect("conversion");
accountant
.compose_subsampled_gaussian(2.0, 0.005, 100)
.expect("segment 2");
let (eps_after_second, _) = accountant.privacy_spent(1.0e-5).expect("conversion");
assert!(eps_after_second > eps_after_first);
assert_eq!(accountant.segments().len(), 2);
assert_eq!(accountant.total_steps(), 200);
}
#[test]
fn test_build_accountant_selects_method() {
let renyi = build_accountant(AccountingMethod::RenyiDP, 1.0, 1e-5, 100, 10_000)
.expect("renyi is implemented");
assert!(matches!(renyi.method(), AccountingMethod::RenyiDP));
let moments = build_accountant(AccountingMethod::MomentsAccountant, 1.0, 1e-5, 100, 10_000)
.expect("moments is implemented");
assert!(matches!(
moments.method(),
AccountingMethod::MomentsAccountant
));
}
#[test]
fn test_build_accountant_rejects_unimplemented_methods() {
for method in [
AccountingMethod::AdvancedComposition,
AccountingMethod::ZCDP,
] {
match build_accountant(method, 1.0, 1e-5, 100, 10_000) {
Err(OptimError::InvalidConfig(_)) => {}
other => panic!("expected InvalidConfig for {method:?}, got {other:?}"),
}
}
}
#[test]
fn test_reset_clears_spend_and_ledger() {
let mut accountant = RenyiPrivacyAccountant::new();
accountant
.compose_subsampled_gaussian(1.0, 0.01, 100)
.expect("composition");
accountant.reset();
assert_eq!(accountant.total_steps(), 0);
assert!(accountant.segments().is_empty());
let (eps, _) = accountant.privacy_spent(1.0e-5).expect("conversion");
assert_eq!(eps, 0.0);
}
}