Skip to main content

optirs_core/privacy/
mod.rs

1// Differential Privacy support for optimizers
2//
3// This module provides differential privacy mechanisms for machine learning
4// optimization, including DP-SGD with a Renyi/moments accountant for privacy
5// budget tracking.
6//
7// # Reading the privacy numbers this module reports
8//
9// * **Epsilon is the budget.** It accumulates with every mechanism
10//   application and is enforced *before* a noisy gradient is released.
11// * **Delta is a reporting parameter, not a spend.** The same composed
12//   mechanism can be described at any delta, trading it against epsilon.
13//   Nothing in this crate consumes delta additively; an implementation that
14//   did would exhaust itself on the first step.
15// * **Per-example clipping is what makes DP-SGD private.** Use
16//   [`DifferentiallyPrivateOptimizer::dp_step_per_example`] (or
17//   [`DifferentiallyPrivateOptimizer::dp_step_presummed`] if you clip and sum
18//   yourself). Clipping an already-aggregated batch gradient does *not* bound
19//   any single example's influence and therefore does not deliver the
20//   standard DP-SGD guarantee -- see
21//   [`DifferentiallyPrivateOptimizer::dp_step`].
22
23use crate::error::{OptimError, Result};
24use scirs2_core::ndarray::{Array, ArrayBase, Data, DataMut, Dimension, ScalarOperand, Zip};
25use scirs2_core::numeric::Float;
26use scirs2_core::random::thread_rng;
27use std::collections::VecDeque;
28use std::fmt::Debug;
29
30pub mod accountant;
31pub mod byzantine_tolerance;
32pub mod differential_privacy; // New modular differential privacy
33pub mod dp_sgd;
34pub mod enhanced_audit;
35pub mod federated; // New modular federated privacy
36pub mod federated_privacy;
37pub mod moment_accountant;
38pub mod noise_mechanisms;
39pub mod private_hyperparameter_optimization;
40pub mod renyi_accountant;
41pub mod secure_aggregation;
42pub mod secure_multiparty;
43pub mod utility_analysis;
44
45use crate::optimizers::Optimizer;
46
47// Re-export key utility analysis types
48pub use utility_analysis::{
49    AnalysisConfig, AnalysisMetadata, BudgetRecommendations, OptimalConfiguration, ParetoPoint,
50    PrivacyConfiguration, PrivacyParameterSpace, PrivacyRiskAssessment, PrivacyUtilityAnalyzer,
51    PrivacyUtilityResults, RobustnessResults, SensitivityResults, StatisticalTestResults,
52    UtilityMetric,
53};
54
55// Re-export modular federated privacy types
56pub use federated::{
57    ByzantineRobustAggregator, ByzantineRobustConfig, ByzantineRobustMethod, ClientComposition,
58    CompositionStats, CrossDeviceConfig, CrossDevicePrivacyManager, DeviceProfile, DeviceType,
59    FederatedCompositionAnalyzer, FederatedCompositionMethod, OutlierDetectionResult,
60    ReputationSystemConfig, RoundComposition, SecureAggregationConfig, SecureAggregationPlan,
61    SecureAggregator, SeedSharingMethod, StatisticalTestConfig, StatisticalTestType, TemporalEvent,
62    TemporalEventType,
63};
64
65// Re-export modular differential privacy types
66pub use differential_privacy::{
67    AmplificationConfig, AmplificationStats, PrivacyAmplificationAnalyzer, SubsamplingEvent,
68};
69
70// Re-export Renyi differential privacy accountant types
71pub use renyi_accountant::{DpConversion, RdpSpend, RenyiAccountant};
72
73// Re-export the unified accounting interface.
74pub use accountant::{
75    build_accountant, AccountingSegment, MomentsPrivacyAccountant, PrivacyAccountant,
76    PrivacyLedger, RenyiPrivacyAccountant,
77};
78
79/// The moments accountant of Abadi et al. (2016).
80///
81/// There is exactly one implementation of this type in the crate; an earlier
82/// revision shipped two divergent copies that disagreed by a factor of 436.
83pub use moment_accountant::MomentsAccountant;
84
85/// Differential privacy configuration
86#[derive(Debug, Clone)]
87pub struct DifferentialPrivacyConfig {
88    /// Target privacy parameter epsilon (the budget that is enforced)
89    pub target_epsilon: f64,
90
91    /// Delta at which epsilon is reported (typically << 1/n). This is a
92    /// reporting parameter, not an additively consumed budget.
93    pub target_delta: f64,
94
95    /// Noise multiplier for gradient perturbation (sigma, relative to the
96    /// clipping norm)
97    pub noise_multiplier: f64,
98
99    /// L2 norm clipping threshold applied to each *per-example* gradient
100    pub l2_norm_clip: f64,
101
102    /// Expected batch size (used for the sampling probability)
103    pub batch_size: usize,
104
105    /// Dataset size for privacy accounting
106    pub dataset_size: usize,
107
108    /// Maximum number of training steps (enforced)
109    pub max_steps: usize,
110
111    /// Noise mechanism to use
112    pub noise_mechanism: NoiseMechanism,
113
114    /// Enable secure aggregation (for federated learning). When set, the
115    /// optimizer refuses to run unless a secure aggregation backend has been
116    /// wired in, rather than silently training without it.
117    pub secure_aggregation: bool,
118
119    /// Enable adaptive clipping (Andrew et al. 2021, differentially private
120    /// quantile estimation)
121    pub adaptive_clipping: bool,
122
123    /// Initial clipping threshold for adaptive clipping
124    pub adaptive_clip_init: f64,
125
126    /// Learning rate (geometric update rate) for adaptive clipping
127    pub adaptive_clip_lr: f64,
128
129    /// Target fraction of per-example gradients that should fall *below* the
130    /// clipping threshold (Andrew et al. 2021 use 0.5)
131    pub adaptive_clip_target_quantile: f64,
132
133    /// Noise multiplier applied to the privatized above-threshold count used
134    /// by adaptive clipping. The count has sensitivity 1, so this is the
135    /// standard deviation of the Gaussian added to it.
136    pub adaptive_clip_noise_multiplier: f64,
137
138    /// Privacy accounting method
139    pub accounting_method: AccountingMethod,
140
141    /// Explicit acknowledgement that the caller understands the semantics of
142    /// [`DifferentiallyPrivateOptimizer::dp_step`], which clips an already
143    /// aggregated gradient and therefore does **not** provide per-example
144    /// differential privacy. Left `false`, that entry point returns an error.
145    pub acknowledge_aggregate_clipping: bool,
146}
147
148impl Default for DifferentialPrivacyConfig {
149    fn default() -> Self {
150        Self {
151            target_epsilon: 1.0,
152            target_delta: 1e-5,
153            noise_multiplier: 1.1,
154            l2_norm_clip: 1.0,
155            batch_size: 256,
156            dataset_size: 50000,
157            max_steps: 1000,
158            noise_mechanism: NoiseMechanism::Gaussian,
159            secure_aggregation: false,
160            adaptive_clipping: false,
161            adaptive_clip_init: 1.0,
162            adaptive_clip_lr: 0.2,
163            adaptive_clip_target_quantile: 0.5,
164            adaptive_clip_noise_multiplier: 1.0,
165            accounting_method: AccountingMethod::RenyiDP,
166            acknowledge_aggregate_clipping: false,
167        }
168    }
169}
170
171impl DifferentialPrivacyConfig {
172    /// Validate the configuration.
173    ///
174    /// Every parameter that can silently void a privacy guarantee is checked
175    /// here: a zero noise multiplier, a non-positive clipping norm, a delta
176    /// outside `(0, 1)`, a batch larger than the dataset, and so on.
177    pub fn validate(&self) -> Result<()> {
178        if !self.target_epsilon.is_finite() || self.target_epsilon <= 0.0 {
179            return Err(OptimError::InvalidPrivacyConfig(format!(
180                "target_epsilon must be a positive finite number, got {}",
181                self.target_epsilon
182            )));
183        }
184        if !self.target_delta.is_finite() || self.target_delta <= 0.0 || self.target_delta >= 1.0 {
185            return Err(OptimError::InvalidPrivacyConfig(format!(
186                "target_delta must be in (0, 1), got {}",
187                self.target_delta
188            )));
189        }
190        if !self.noise_multiplier.is_finite() || self.noise_multiplier <= 0.0 {
191            return Err(OptimError::InvalidPrivacyConfig(format!(
192                "noise_multiplier must be a positive finite number, got {}",
193                self.noise_multiplier
194            )));
195        }
196        if !self.l2_norm_clip.is_finite() || self.l2_norm_clip <= 0.0 {
197            return Err(OptimError::InvalidPrivacyConfig(format!(
198                "l2_norm_clip must be a positive finite number, got {}",
199                self.l2_norm_clip
200            )));
201        }
202        if self.batch_size == 0 || self.dataset_size == 0 {
203            return Err(OptimError::InvalidPrivacyConfig(
204                "batch_size and dataset_size must be positive".to_string(),
205            ));
206        }
207        if self.batch_size > self.dataset_size {
208            return Err(OptimError::InvalidPrivacyConfig(
209                "batch_size cannot exceed dataset_size".to_string(),
210            ));
211        }
212        if self.max_steps == 0 {
213            return Err(OptimError::InvalidPrivacyConfig(
214                "max_steps must be positive".to_string(),
215            ));
216        }
217        if self.adaptive_clipping {
218            if !self.adaptive_clip_init.is_finite() || self.adaptive_clip_init <= 0.0 {
219                return Err(OptimError::InvalidPrivacyConfig(format!(
220                    "adaptive_clip_init must be a positive finite number, got {}",
221                    self.adaptive_clip_init
222                )));
223            }
224            if !self.adaptive_clip_lr.is_finite() || self.adaptive_clip_lr <= 0.0 {
225                return Err(OptimError::InvalidPrivacyConfig(format!(
226                    "adaptive_clip_lr must be a positive finite number, got {}",
227                    self.adaptive_clip_lr
228                )));
229            }
230            if !(0.0..=1.0).contains(&self.adaptive_clip_target_quantile) {
231                return Err(OptimError::InvalidPrivacyConfig(format!(
232                    "adaptive_clip_target_quantile must be in [0, 1], got {}",
233                    self.adaptive_clip_target_quantile
234                )));
235            }
236            if !self.adaptive_clip_noise_multiplier.is_finite()
237                || self.adaptive_clip_noise_multiplier <= 0.0
238            {
239                return Err(OptimError::InvalidPrivacyConfig(format!(
240                    "adaptive_clip_noise_multiplier must be positive and finite, got {}",
241                    self.adaptive_clip_noise_multiplier
242                )));
243            }
244        }
245        Ok(())
246    }
247
248    /// Per-record sampling probability `q = batch_size / dataset_size`.
249    pub fn sampling_probability(&self) -> f64 {
250        if self.dataset_size == 0 {
251            0.0
252        } else {
253            (self.batch_size as f64 / self.dataset_size as f64).min(1.0)
254        }
255    }
256}
257
258/// Noise mechanisms for differential privacy
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum NoiseMechanism {
261    /// Gaussian noise mechanism ((epsilon, delta)-DP)
262    Gaussian,
263    /// Laplace noise mechanism (pure epsilon-DP, delta = 0)
264    Laplace,
265    /// Tree aggregation with Gaussian noise (not implemented for the
266    /// optimizer path; selecting it returns an error rather than silently
267    /// using a different mechanism)
268    TreeAggregation,
269    /// Improved composition with amplification (not implemented for the
270    /// optimizer path; selecting it returns an error)
271    ImprovedComposition,
272}
273
274/// Privacy budget tracking information
275#[derive(Debug, Clone)]
276pub struct PrivacyBudget {
277    /// Current epsilon consumed
278    pub epsilon_consumed: f64,
279
280    /// Delta *consumed*. Always 0.0: delta is a reporting parameter, not an
281    /// additive spend. Retained for API compatibility.
282    pub delta_consumed: f64,
283
284    /// Remaining epsilon budget
285    pub epsilon_remaining: f64,
286
287    /// The delta at which `epsilon_consumed` is reported. Since delta is not
288    /// consumed, the full reporting delta always "remains".
289    pub delta_remaining: f64,
290
291    /// Number of steps taken
292    pub steps_taken: usize,
293
294    /// Privacy accounting method used
295    pub accounting_method: AccountingMethod,
296
297    /// Estimated steps until budget exhaustion
298    pub estimated_steps_remaining: usize,
299}
300
301impl Default for PrivacyBudget {
302    fn default() -> Self {
303        Self {
304            epsilon_consumed: 0.0,
305            delta_consumed: 0.0,
306            epsilon_remaining: 1.0,
307            delta_remaining: 1e-5,
308            steps_taken: 0,
309            accounting_method: AccountingMethod::RenyiDP,
310            estimated_steps_remaining: 1000,
311        }
312    }
313}
314
315/// Privacy accounting methods
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum AccountingMethod {
318    /// Moments accountant (Abadi et al. 2016)
319    MomentsAccountant,
320    /// Renyi differential privacy (default; tightest available here)
321    RenyiDP,
322    /// Advanced composition (not implemented for the subsampled Gaussian
323    /// mechanism; selecting it returns an error)
324    AdvancedComposition,
325    /// Zero-concentrated differential privacy (not implemented; selecting it
326    /// returns an error)
327    ZCDP,
328}
329
330/// Differentially private optimizer wrapper
331pub struct DifferentiallyPrivateOptimizer<O, A, D>
332where
333    A: Float + ScalarOperand + Debug + Send + Sync,
334    D: Dimension,
335    O: Optimizer<A, D>,
336{
337    /// Base optimizer
338    base_optimizer: O,
339
340    /// Privacy configuration
341    config: DifferentialPrivacyConfig,
342
343    /// Privacy accountant selected from `config.accounting_method`
344    accountant: Box<dyn PrivacyAccountant>,
345
346    /// Pure epsilon-DP ledger, used by the Laplace mechanism (which provides
347    /// delta = 0 and therefore does not compose through the RDP accountant)
348    pure_epsilon_spent: f64,
349
350    /// Random number generator for noise (seeded from OS entropy)
351    rng: scirs2_core::random::CoreRandom,
352
353    /// Adaptive clipping state
354    adaptive_clip_state: Option<AdaptiveClippingState>,
355
356    /// Gradient history for analysis
357    gradient_history: VecDeque<GradientNorms>,
358
359    /// Privacy audit trail
360    audit_trail: Vec<PrivacyEvent>,
361
362    /// Current step count
363    step_count: usize,
364
365    /// Phantom data for unused type parameters
366    _phantom: std::marker::PhantomData<(A, D)>,
367}
368
369/// Adaptive clipping state (Andrew et al. 2021).
370#[derive(Debug, Clone)]
371struct AdaptiveClippingState {
372    /// Current clipping threshold C
373    current_threshold: f64,
374
375    /// Target fraction of gradients below the threshold
376    target_quantile: f64,
377
378    /// Geometric update rate
379    learning_rate: f64,
380
381    /// Most recent privatized below-threshold fraction
382    last_fraction_estimate: f64,
383
384    /// Number of privatized quantile updates performed
385    updates: usize,
386}
387
388/// Gradient norm statistics
389#[derive(Debug, Clone)]
390struct GradientNorms {
391    #[allow(dead_code)]
392    step: usize,
393    pre_clip_norm: f64,
394    #[allow(dead_code)]
395    post_clip_norm: f64,
396    clipping_ratio: f64,
397    clipped: bool,
398}
399
400/// Privacy event for audit trail
401#[derive(Debug, Clone)]
402pub struct PrivacyEvent {
403    /// Step at which the event occurred.
404    pub step: usize,
405    /// Kind of release.
406    pub event_type: PrivacyEventType,
407    /// Cumulative epsilon after the event.
408    pub epsilon_spent: f64,
409    /// Delta the epsilon is reported at.
410    pub reporting_delta: f64,
411    /// Standard deviation of the noise added by this release.
412    pub noise_scale: f64,
413}
414
415/// Kind of privacy-consuming release recorded in the audit trail.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum PrivacyEventType {
418    /// A privatized gradient was released.
419    GradientRelease,
420    /// A model update was released.
421    ModelUpdate,
422    /// A parameter query was answered.
423    ParameterQuery,
424    /// A privatized clipping-threshold update was released.
425    AdaptiveClipUpdate,
426}
427
428impl<O, A, D> DifferentiallyPrivateOptimizer<O, A, D>
429where
430    A: Float
431        + std::ops::AddAssign
432        + std::ops::SubAssign
433        + Send
434        + Sync
435        + scirs2_core::ndarray::ScalarOperand
436        + std::fmt::Debug,
437    D: Dimension,
438    O: Optimizer<A, D>,
439{
440    /// Create a new differentially private optimizer.
441    ///
442    /// The configuration is validated up front, and the accountant named by
443    /// `config.accounting_method` is constructed (an unimplemented method is
444    /// an error, never a silent substitution).
445    pub fn new(baseoptimizer: O, config: DifferentialPrivacyConfig) -> Result<Self> {
446        config.validate()?;
447
448        if config.secure_aggregation {
449            return Err(OptimError::InvalidPrivacyConfig(
450                "secure_aggregation is not wired into DifferentiallyPrivateOptimizer; use \
451                 privacy::secure_aggregation::SecureAggregator explicitly instead of enabling \
452                 this flag, which would otherwise train with plaintext aggregation"
453                    .to_string(),
454            ));
455        }
456
457        let accountant = build_accountant(
458            config.accounting_method,
459            config.noise_multiplier,
460            config.target_delta,
461            config.batch_size,
462            config.dataset_size,
463        )?;
464
465        let rng = thread_rng();
466
467        let adaptive_clip_state = if config.adaptive_clipping {
468            Some(AdaptiveClippingState {
469                current_threshold: config.adaptive_clip_init,
470                target_quantile: config.adaptive_clip_target_quantile,
471                learning_rate: config.adaptive_clip_lr,
472                last_fraction_estimate: config.adaptive_clip_target_quantile,
473                updates: 0,
474            })
475        } else {
476            None
477        };
478
479        Ok(Self {
480            base_optimizer: baseoptimizer,
481            config,
482            accountant,
483            pure_epsilon_spent: 0.0,
484            rng,
485            adaptive_clip_state,
486            gradient_history: VecDeque::with_capacity(1000),
487            audit_trail: Vec::new(),
488            step_count: 0,
489            _phantom: std::marker::PhantomData,
490        })
491    }
492
493    /// Perform a differentially private step from **per-example** gradients.
494    ///
495    /// This is the entry point that delivers the standard DP-SGD guarantee:
496    ///
497    /// 1. every per-example gradient is clipped to the current L2 threshold
498    ///    `C`, bounding one example's influence on the sum by `C`;
499    /// 2. the clipped gradients are summed;
500    /// 3. `N(0, sigma^2 C^2)` noise is added to the **sum** (adding it after
501    ///    the division would silently reduce the effective sigma by the batch
502    ///    size);
503    /// 4. the noisy sum is divided by the batch size.
504    ///
505    /// Adjacency: add/remove-one-example, for which the L2 sensitivity of the
506    /// clipped sum is exactly `C`.
507    ///
508    /// The privacy budget is enforced *before* the noisy gradient is
509    /// released: if composing this step would push epsilon past
510    /// `target_epsilon`, the call returns
511    /// [`OptimError::PrivacyBudgetExhausted`] and nothing is released.
512    pub fn dp_step_per_example(
513        &mut self,
514        params: &Array<A, D>,
515        per_example_gradients: &[Array<A, D>],
516    ) -> Result<Array<A, D>> {
517        if per_example_gradients.is_empty() {
518            return Err(OptimError::InvalidConfig(
519                "dp_step_per_example requires at least one per-example gradient".to_string(),
520            ));
521        }
522        for gradient in per_example_gradients {
523            if gradient.raw_dim() != params.raw_dim() {
524                return Err(OptimError::DimensionMismatch(format!(
525                    "per-example gradient shape {:?} does not match parameter shape {:?}",
526                    gradient.shape(),
527                    params.shape()
528                )));
529            }
530        }
531
532        let batch_size = per_example_gradients.len();
533        self.begin_step(batch_size)?;
534
535        let clip_threshold = self.get_clipping_threshold();
536        let mut summed: Array<A, D> = Array::zeros(params.raw_dim());
537        let mut pre_clip_norm_sum = 0.0;
538        let mut clipped_count = 0usize;
539        let mut below_threshold = 0usize;
540
541        for gradient in per_example_gradients {
542            let norm = self.compute_l2_norm(gradient);
543            if !norm.is_finite() {
544                return Err(OptimError::InvalidConfig(
545                    "per-example gradient contains a non-finite value; clipping cannot bound \
546                     its sensitivity"
547                        .to_string(),
548                ));
549            }
550            pre_clip_norm_sum += norm;
551
552            let scale = if norm > clip_threshold && norm > 0.0 {
553                clipped_count += 1;
554                clip_threshold / norm
555            } else {
556                below_threshold += 1;
557                1.0
558            };
559
560            let scale_a = A::from(scale).ok_or_else(|| {
561                OptimError::InvalidConfig("failed to convert clipping scale".to_string())
562            })?;
563            Zip::from(&mut summed)
564                .and(gradient)
565                .for_each(|acc, &g| *acc += g * scale_a);
566        }
567
568        // Noise the *sum*: sensitivity of the clipped sum is exactly C.
569        let noise_scale = self.config.noise_multiplier * clip_threshold;
570        self.add_mechanism_noise(&mut summed, noise_scale, clip_threshold)?;
571
572        let batch_a = A::from(batch_size as f64)
573            .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
574        summed.mapv_inplace(|x| x / batch_a);
575
576        self.commit_step(batch_size, noise_scale, PrivacyEventType::GradientRelease)?;
577
578        let mean_pre_clip = pre_clip_norm_sum / batch_size as f64;
579        self.record_gradient_stats(mean_pre_clip, clip_threshold, clipped_count, batch_size);
580
581        if self.config.adaptive_clipping {
582            self.update_adaptive_clipping(below_threshold, batch_size)?;
583        }
584
585        self.base_optimizer.step(params, &summed)
586    }
587
588    /// Perform a differentially private step from a **pre-clipped sum** of
589    /// per-example gradients.
590    ///
591    /// The caller asserts that `summed_clipped_gradients` is the sum of
592    /// `batch_size` per-example gradients, each already clipped to
593    /// [`DifferentialPrivacyConfig::l2_norm_clip`]. Noise `N(0, sigma^2 C^2)`
594    /// is added to the sum, which is then divided by `batch_size`.
595    ///
596    /// Use this when per-example gradients are produced by an external
597    /// framework and materialising them all is impractical.
598    pub fn dp_step_presummed(
599        &mut self,
600        params: &Array<A, D>,
601        summed_clipped_gradients: &Array<A, D>,
602        batch_size: usize,
603    ) -> Result<Array<A, D>> {
604        if batch_size == 0 {
605            return Err(OptimError::InvalidConfig(
606                "batch_size must be positive".to_string(),
607            ));
608        }
609        if summed_clipped_gradients.raw_dim() != params.raw_dim() {
610            return Err(OptimError::DimensionMismatch(format!(
611                "gradient shape {:?} does not match parameter shape {:?}",
612                summed_clipped_gradients.shape(),
613                params.shape()
614            )));
615        }
616
617        self.begin_step(batch_size)?;
618
619        let clip_threshold = self.get_clipping_threshold();
620        let mut summed = summed_clipped_gradients.clone();
621        let noise_scale = self.config.noise_multiplier * clip_threshold;
622        self.add_mechanism_noise(&mut summed, noise_scale, clip_threshold)?;
623
624        let batch_a = A::from(batch_size as f64)
625            .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
626        summed.mapv_inplace(|x| x / batch_a);
627
628        self.commit_step(batch_size, noise_scale, PrivacyEventType::GradientRelease)?;
629
630        let norm = self.compute_l2_norm(summed_clipped_gradients) / batch_size as f64;
631        self.record_gradient_stats(norm, clip_threshold, 0, batch_size);
632
633        self.base_optimizer.step(params, &summed)
634    }
635
636    /// Perform a step that clips the **already aggregated** gradient.
637    ///
638    /// # This does not provide per-example differential privacy
639    ///
640    /// Clipping a batch-mean (or batch-sum) gradient bounds the influence of
641    /// the *whole batch*, not of any single example, so the standard DP-SGD
642    /// analysis -- and the epsilon this optimizer reports -- does not apply
643    /// under example-level adjacency. One outlier example can still move the
644    /// aggregate arbitrarily far inside the clipping ball.
645    ///
646    /// The entry point is retained for batch-level adjacency (neighbouring
647    /// datasets differing in an entire batch) and for reproducing legacy
648    /// behaviour. Because subsampling amplification does not apply under that
649    /// adjacency, the step is accounted with sampling probability `q = 1`,
650    /// which is strictly more conservative than the per-example path.
651    ///
652    /// It returns an error unless
653    /// [`DifferentialPrivacyConfig::acknowledge_aggregate_clipping`] is set,
654    /// so nobody gets this behaviour by accident. Prefer
655    /// [`Self::dp_step_per_example`].
656    pub fn dp_step(
657        &mut self,
658        params: &Array<A, D>,
659        gradients: &mut Array<A, D>,
660    ) -> Result<Array<A, D>> {
661        if !self.config.acknowledge_aggregate_clipping {
662            return Err(OptimError::InvalidPrivacyConfig(
663                "dp_step clips an already-aggregated gradient and therefore does NOT provide \
664                 per-example differential privacy. Use dp_step_per_example (or \
665                 dp_step_presummed), or set acknowledge_aggregate_clipping = true to accept \
666                 batch-level adjacency semantics"
667                    .to_string(),
668            ));
669        }
670
671        // Batch-level adjacency: no subsampling amplification.
672        self.begin_step(self.config.dataset_size)?;
673
674        let pre_clip_norm = self.compute_l2_norm(gradients);
675        if !pre_clip_norm.is_finite() {
676            return Err(OptimError::InvalidConfig(
677                "gradient contains a non-finite value; clipping cannot bound its sensitivity"
678                    .to_string(),
679            ));
680        }
681
682        let clip_threshold = self.get_clipping_threshold();
683        let (clipping_ratio, clipped) = if pre_clip_norm > clip_threshold && pre_clip_norm > 0.0 {
684            let scale = clip_threshold / pre_clip_norm;
685            let scale_a = A::from(scale).ok_or_else(|| {
686                OptimError::InvalidConfig("failed to convert clipping scale".to_string())
687            })?;
688            gradients.mapv_inplace(|g| g * scale_a);
689            (scale, true)
690        } else {
691            (1.0, false)
692        };
693
694        let noise_scale = self.config.noise_multiplier * clip_threshold;
695        self.add_mechanism_noise(gradients, noise_scale, clip_threshold)?;
696
697        self.commit_step(
698            self.config.dataset_size,
699            noise_scale,
700            PrivacyEventType::GradientRelease,
701        )?;
702
703        let post_clip_norm = self.compute_l2_norm(gradients);
704        self.gradient_history.push_back(GradientNorms {
705            step: self.step_count,
706            pre_clip_norm,
707            post_clip_norm,
708            clipping_ratio,
709            clipped,
710        });
711        if self.gradient_history.len() > 1000 {
712            self.gradient_history.pop_front();
713        }
714
715        self.base_optimizer.step(params, gradients)
716    }
717
718    /// Enforce the step budget *before* any output is released.
719    fn begin_step(&mut self, batch_size: usize) -> Result<()> {
720        if self.step_count >= self.config.max_steps {
721            return Err(OptimError::PrivacyBudgetExhausted {
722                consumed_epsilon: self.consumed_epsilon()?,
723                target_epsilon: self.config.target_epsilon,
724            });
725        }
726
727        let projected = self.projected_epsilon(batch_size)?;
728        if projected > self.config.target_epsilon {
729            return Err(OptimError::PrivacyBudgetExhausted {
730                consumed_epsilon: self.consumed_epsilon()?,
731                target_epsilon: self.config.target_epsilon,
732            });
733        }
734
735        Ok(())
736    }
737
738    /// Record a released step in the accountant and the audit trail.
739    fn commit_step(
740        &mut self,
741        batch_size: usize,
742        noise_scale: f64,
743        event_type: PrivacyEventType,
744    ) -> Result<()> {
745        self.step_count += 1;
746
747        match self.config.noise_mechanism {
748            NoiseMechanism::Gaussian => {
749                let q = self.sampling_probability(batch_size);
750                self.accountant
751                    .compose_subsampled_gaussian(self.config.noise_multiplier, q, 1)?;
752            }
753            NoiseMechanism::Laplace => {
754                self.pure_epsilon_spent += self.laplace_epsilon_per_step();
755            }
756            other => {
757                return Err(OptimError::InvalidPrivacyConfig(format!(
758                    "noise mechanism {other:?} is not implemented for this optimizer"
759                )));
760            }
761        }
762
763        let epsilon_spent = self.consumed_epsilon()?;
764        self.audit_trail.push(PrivacyEvent {
765            step: self.step_count,
766            event_type,
767            epsilon_spent,
768            reporting_delta: self.reporting_delta(),
769            noise_scale,
770        });
771
772        Ok(())
773    }
774
775    /// Record clipping statistics for the step.
776    fn record_gradient_stats(
777        &mut self,
778        mean_pre_clip_norm: f64,
779        clip_threshold: f64,
780        clipped_count: usize,
781        batch_size: usize,
782    ) {
783        let clipping_ratio = if mean_pre_clip_norm > clip_threshold && mean_pre_clip_norm > 0.0 {
784            clip_threshold / mean_pre_clip_norm
785        } else {
786            1.0
787        };
788
789        self.gradient_history.push_back(GradientNorms {
790            step: self.step_count,
791            pre_clip_norm: mean_pre_clip_norm,
792            post_clip_norm: mean_pre_clip_norm.min(clip_threshold),
793            clipping_ratio,
794            clipped: clipped_count * 2 > batch_size,
795        });
796
797        if self.gradient_history.len() > 1000 {
798            self.gradient_history.pop_front();
799        }
800    }
801
802    /// Differentially private clipping-threshold update (Andrew et al. 2021,
803    /// "Differentially Private Learning with Adaptive Clipping").
804    ///
805    /// The fraction of per-example gradients whose norm falls below the
806    /// current threshold is a counting query with sensitivity 1. It is
807    /// released with Gaussian noise -- charged to the privacy budget like any
808    /// other release -- and the threshold is updated geometrically:
809    ///
810    /// ```text
811    /// C <- C * exp(-eta * (fraction_below - target_quantile))
812    /// ```
813    ///
814    /// Using the *raw* norms (as an earlier revision did) leaks the gradient
815    /// distribution and is not differentially private at any epsilon.
816    fn update_adaptive_clipping(
817        &mut self,
818        below_threshold: usize,
819        batch_size: usize,
820    ) -> Result<()> {
821        if batch_size == 0 {
822            return Ok(());
823        }
824
825        let sigma_count = self.config.adaptive_clip_noise_multiplier;
826        let noise = sigma_count * standard_normal(&mut self.rng);
827        let noisy_below = below_threshold as f64 + noise;
828        let fraction = (noisy_below / batch_size as f64).clamp(0.0, 1.0);
829
830        let (target, learning_rate) = match self.adaptive_clip_state {
831            Some(ref state) => (state.target_quantile, state.learning_rate),
832            None => return Ok(()),
833        };
834
835        let factor = (-learning_rate * (fraction - target)).exp();
836        let updated = if let Some(ref mut state) = self.adaptive_clip_state {
837            state.last_fraction_estimate = fraction;
838            state.updates += 1;
839            state.current_threshold = (state.current_threshold * factor).clamp(1e-6, 1e6);
840            state.current_threshold
841        } else {
842            return Ok(());
843        };
844
845        // The privatized count is an extra Gaussian release and must be paid
846        // for. Sensitivity of the count is 1, so `sigma_count` is directly
847        // the noise multiplier of that mechanism.
848        match self.config.noise_mechanism {
849            NoiseMechanism::Gaussian => {
850                let q = self.sampling_probability(batch_size);
851                self.accountant
852                    .compose_subsampled_gaussian(sigma_count, q, 1)?;
853            }
854            NoiseMechanism::Laplace => {
855                // Charged with the same per-step pure-epsilon allowance.
856                self.pure_epsilon_spent += self.laplace_epsilon_per_step();
857            }
858            other => {
859                return Err(OptimError::InvalidPrivacyConfig(format!(
860                    "noise mechanism {other:?} is not implemented for this optimizer"
861                )));
862            }
863        }
864
865        let epsilon_spent = self.consumed_epsilon()?;
866        self.audit_trail.push(PrivacyEvent {
867            step: self.step_count,
868            event_type: PrivacyEventType::AdaptiveClipUpdate,
869            epsilon_spent,
870            reporting_delta: self.reporting_delta(),
871            noise_scale: sigma_count,
872        });
873
874        debug_assert!(updated > 0.0);
875        Ok(())
876    }
877
878    /// Sampling probability for a batch of the given size.
879    fn sampling_probability(&self, batch_size: usize) -> f64 {
880        if self.config.dataset_size == 0 {
881            0.0
882        } else {
883            (batch_size as f64 / self.config.dataset_size as f64).min(1.0)
884        }
885    }
886
887    /// Per-step epsilon allowance of the pure epsilon-DP (Laplace) path.
888    ///
889    /// Pure DP composes basically: the target budget is divided evenly over
890    /// the configured maximum number of steps.
891    fn laplace_epsilon_per_step(&self) -> f64 {
892        self.config.target_epsilon / self.config.max_steps.max(1) as f64
893    }
894
895    /// Delta at which the reported epsilon holds (0 for pure epsilon-DP).
896    fn reporting_delta(&self) -> f64 {
897        match self.config.noise_mechanism {
898            NoiseMechanism::Laplace => 0.0,
899            _ => self.config.target_delta,
900        }
901    }
902
903    /// Epsilon consumed so far.
904    ///
905    /// Errors from the accountant are propagated rather than swallowed: an
906    /// accounting failure must never be reported as "zero spent", which would
907    /// let training continue with no budget enforcement at all.
908    pub fn consumed_epsilon(&self) -> Result<f64> {
909        match self.config.noise_mechanism {
910            NoiseMechanism::Laplace => Ok(self.pure_epsilon_spent),
911            _ => {
912                let (epsilon, _) = self.accountant.privacy_spent(self.config.target_delta)?;
913                Ok(epsilon)
914            }
915        }
916    }
917
918    /// Epsilon that composing one more step with the given batch size would
919    /// bring the total to.
920    fn projected_epsilon(&self, batch_size: usize) -> Result<f64> {
921        match self.config.noise_mechanism {
922            NoiseMechanism::Laplace => {
923                Ok(self.pure_epsilon_spent + self.laplace_epsilon_per_step())
924            }
925            NoiseMechanism::Gaussian => {
926                let q = self.sampling_probability(batch_size);
927                let (epsilon, _) = self.accountant.projected_privacy_spent(
928                    self.config.noise_multiplier,
929                    q,
930                    1,
931                    self.config.target_delta,
932                )?;
933                Ok(epsilon)
934            }
935            other => Err(OptimError::InvalidPrivacyConfig(format!(
936                "noise mechanism {other:?} is not implemented for this optimizer"
937            ))),
938        }
939    }
940
941    /// Whether at least one more step fits inside the epsilon budget.
942    pub fn has_privacy_budget(&self) -> Result<bool> {
943        if self.step_count >= self.config.max_steps {
944            return Ok(false);
945        }
946        let projected = self.projected_epsilon(self.config.batch_size)?;
947        Ok(projected <= self.config.target_epsilon)
948    }
949
950    /// Current privacy budget status.
951    ///
952    /// Returns an error if the accountant cannot produce a number -- failing
953    /// closed instead of reporting a fabricated zero spend.
954    pub fn get_privacy_budget(&self) -> Result<PrivacyBudget> {
955        let epsilon_consumed = self.consumed_epsilon()?;
956        let epsilon_remaining = (self.config.target_epsilon - epsilon_consumed).max(0.0);
957
958        let epsilon_per_step = if self.step_count > 0 && epsilon_consumed.is_finite() {
959            epsilon_consumed / self.step_count as f64
960        } else {
961            0.0
962        };
963
964        let estimated_steps_remaining = if epsilon_per_step > 0.0 {
965            let by_budget = (epsilon_remaining / epsilon_per_step) as usize;
966            by_budget.min(self.config.max_steps.saturating_sub(self.step_count))
967        } else {
968            self.config.max_steps.saturating_sub(self.step_count)
969        };
970
971        Ok(PrivacyBudget {
972            epsilon_consumed,
973            delta_consumed: 0.0,
974            epsilon_remaining,
975            delta_remaining: self.reporting_delta(),
976            steps_taken: self.step_count,
977            accounting_method: self.config.accounting_method,
978            estimated_steps_remaining,
979        })
980    }
981
982    /// Immutable view of the accountant's segment ledger.
983    pub fn accounting_segments(&self) -> &[AccountingSegment] {
984        self.accountant.segments()
985    }
986
987    /// The configuration in force.
988    pub fn config(&self) -> &DifferentialPrivacyConfig {
989        &self.config
990    }
991
992    fn compute_l2_norm<S, DIM>(&self, array: &ArrayBase<S, DIM>) -> f64
993    where
994        S: Data<Elem = A>,
995        DIM: Dimension,
996    {
997        array
998            .iter()
999            .map(|&x| {
1000                let val = x.to_f64().unwrap_or(f64::NAN);
1001                val * val
1002            })
1003            .sum::<f64>()
1004            .sqrt()
1005    }
1006
1007    /// Current clipping threshold (adaptive if enabled).
1008    pub fn get_clipping_threshold(&self) -> f64 {
1009        if let Some(ref state) = self.adaptive_clip_state {
1010            state.current_threshold
1011        } else {
1012            self.config.l2_norm_clip
1013        }
1014    }
1015
1016    /// Add mechanism noise to a gradient container.
1017    ///
1018    /// * Gaussian: `N(0, (sigma * C)^2)` per coordinate.
1019    /// * Laplace: `Lap(b)` with `b = sensitivity / epsilon_step`, where the
1020    ///   L1 sensitivity is bounded by `sqrt(d) * C` for an L2-clipped
1021    ///   gradient of dimension `d`. This is what actually delivers pure
1022    ///   epsilon-DP; sampling a Gaussian and calling it Laplace does not.
1023    /// * Every other variant is an error rather than a silent fallback.
1024    fn add_mechanism_noise<S, DIM>(
1025        &mut self,
1026        gradients: &mut ArrayBase<S, DIM>,
1027        gaussian_noise_scale: f64,
1028        clip_threshold: f64,
1029    ) -> Result<()>
1030    where
1031        S: DataMut<Elem = A>,
1032        DIM: Dimension,
1033    {
1034        match self.config.noise_mechanism {
1035            NoiseMechanism::Gaussian => {
1036                let sigma = gaussian_noise_scale;
1037                let mut failed = false;
1038                gradients.mapv_inplace(|g| {
1039                    let sample = standard_normal(&mut self.rng) * sigma;
1040                    match A::from(sample) {
1041                        Some(noise) => g + noise,
1042                        None => {
1043                            failed = true;
1044                            g
1045                        }
1046                    }
1047                });
1048                if failed {
1049                    return Err(OptimError::InvalidConfig(
1050                        "failed to convert Gaussian noise sample into the gradient element type"
1051                            .to_string(),
1052                    ));
1053                }
1054            }
1055            NoiseMechanism::Laplace => {
1056                let dimension = gradients.len().max(1) as f64;
1057                let l1_sensitivity = clip_threshold * dimension.sqrt();
1058                let epsilon_step = self.laplace_epsilon_per_step();
1059                if epsilon_step <= 0.0 {
1060                    return Err(OptimError::InvalidPrivacyConfig(
1061                        "Laplace mechanism requires a positive per-step epsilon".to_string(),
1062                    ));
1063                }
1064                let scale = l1_sensitivity / epsilon_step;
1065                let mut failed = false;
1066                gradients.mapv_inplace(|g| {
1067                    let sample = standard_laplace(&mut self.rng) * scale;
1068                    match A::from(sample) {
1069                        Some(noise) => g + noise,
1070                        None => {
1071                            failed = true;
1072                            g
1073                        }
1074                    }
1075                });
1076                if failed {
1077                    return Err(OptimError::InvalidConfig(
1078                        "failed to convert Laplace noise sample into the gradient element type"
1079                            .to_string(),
1080                    ));
1081                }
1082            }
1083            other => {
1084                return Err(OptimError::InvalidPrivacyConfig(format!(
1085                    "noise mechanism {other:?} is not implemented for this optimizer; use \
1086                     Gaussian or Laplace"
1087                )));
1088            }
1089        }
1090
1091        Ok(())
1092    }
1093
1094    /// Gradient clipping statistics.
1095    pub fn get_clipping_stats(&self) -> ClippingStats {
1096        if self.gradient_history.is_empty() {
1097            return ClippingStats {
1098                current_threshold: self.get_clipping_threshold(),
1099                ..ClippingStats::default()
1100            };
1101        }
1102
1103        let total_steps = self.gradient_history.len();
1104        let clipped_steps = self
1105            .gradient_history
1106            .iter()
1107            .filter(|stats| stats.clipped)
1108            .count();
1109
1110        let avg_clipping_ratio: f64 = self
1111            .gradient_history
1112            .iter()
1113            .map(|stats| stats.clipping_ratio)
1114            .sum::<f64>()
1115            / total_steps as f64;
1116
1117        let avg_pre_clip_norm: f64 = self
1118            .gradient_history
1119            .iter()
1120            .map(|stats| stats.pre_clip_norm)
1121            .sum::<f64>()
1122            / total_steps as f64;
1123
1124        ClippingStats {
1125            total_steps,
1126            clipped_steps,
1127            clipping_frequency: clipped_steps as f64 / total_steps as f64,
1128            avg_clipping_ratio,
1129            avg_pre_clip_norm,
1130            current_threshold: self.get_clipping_threshold(),
1131        }
1132    }
1133
1134    /// Privacy audit trail.
1135    pub fn get_audit_trail(&self) -> &[PrivacyEvent] {
1136        &self.audit_trail
1137    }
1138
1139    /// Validate privacy guarantees against the configured budget.
1140    pub fn validate_privacy(&self) -> Result<PrivacyValidation> {
1141        let budget = self.get_privacy_budget()?;
1142        let clipping_stats = self.get_clipping_stats();
1143
1144        let mut warnings = Vec::new();
1145        let mut is_valid = true;
1146
1147        if !budget.epsilon_consumed.is_finite() {
1148            warnings.push(
1149                "Privacy accounting saturated: the configured noise provides no usable guarantee"
1150                    .to_string(),
1151            );
1152            is_valid = false;
1153        } else if budget.epsilon_consumed > self.config.target_epsilon {
1154            warnings.push("Epsilon budget exceeded".to_string());
1155            is_valid = false;
1156        }
1157
1158        if clipping_stats.total_steps > 0 {
1159            if clipping_stats.clipping_frequency < 0.1 {
1160                warnings.push(
1161                    "Low clipping frequency may indicate sub-optimal privacy-utility tradeoff"
1162                        .to_string(),
1163                );
1164            }
1165            if clipping_stats.clipping_frequency > 0.9 {
1166                warnings.push("High clipping frequency may severely impact utility".to_string());
1167            }
1168        }
1169
1170        let recommendations = self.generate_recommendations(&budget, &clipping_stats);
1171
1172        Ok(PrivacyValidation {
1173            is_valid,
1174            budget,
1175            clipping_stats,
1176            warnings,
1177            recommendations,
1178        })
1179    }
1180
1181    fn generate_recommendations(
1182        &self,
1183        budget: &PrivacyBudget,
1184        clipping: &ClippingStats,
1185    ) -> Vec<String> {
1186        let mut recommendations = Vec::new();
1187
1188        if clipping.total_steps > 0 {
1189            if clipping.clipping_frequency > 0.8 {
1190                recommendations.push("Consider increasing the clipping threshold".to_string());
1191            }
1192            if clipping.clipping_frequency < 0.2 {
1193                recommendations.push("Consider decreasing the clipping threshold".to_string());
1194            }
1195        }
1196
1197        if budget.epsilon_remaining < budget.epsilon_consumed * 0.1 {
1198            recommendations.push(
1199                "Privacy budget nearly exhausted - increase the noise multiplier or stop training"
1200                    .to_string(),
1201            );
1202        }
1203
1204        recommendations
1205    }
1206}
1207
1208/// Draw a standard normal sample via the Box-Muller transform.
1209///
1210/// `gen_range(0.0..1.0)` *includes* 0.0, and `ln(0) = -inf` would poison the
1211/// gradient with an infinite noise value. Mapping the draw into `(0, 1]`
1212/// removes that failure mode entirely.
1213pub(crate) fn standard_normal(rng: &mut scirs2_core::random::CoreRandom) -> f64 {
1214    let u1: f64 = 1.0 - rng.gen_range(0.0..1.0);
1215    let u2: f64 = rng.gen_range(0.0..1.0);
1216    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
1217}
1218
1219/// Draw a `Laplace(0, 1)` sample by inverse transform sampling.
1220///
1221/// `u` is mapped strictly inside `(0, 1)` so neither tail evaluates `ln(0)`.
1222pub(crate) fn standard_laplace(rng: &mut scirs2_core::random::CoreRandom) -> f64 {
1223    let raw: f64 = rng.gen_range(0.0..1.0);
1224    let u = raw.clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
1225    if u < 0.5 {
1226        (2.0 * u).ln()
1227    } else {
1228        -(2.0 * (1.0 - u)).ln()
1229    }
1230}
1231
1232/// Gradient clipping statistics
1233#[derive(Debug, Clone)]
1234pub struct ClippingStats {
1235    /// Number of recorded steps.
1236    pub total_steps: usize,
1237    /// Steps in which clipping was active.
1238    pub clipped_steps: usize,
1239    /// Fraction of steps in which clipping was active.
1240    pub clipping_frequency: f64,
1241    /// Mean scaling factor applied by clipping.
1242    pub avg_clipping_ratio: f64,
1243    /// Mean pre-clipping gradient norm.
1244    pub avg_pre_clip_norm: f64,
1245    /// Clipping threshold currently in force.
1246    pub current_threshold: f64,
1247}
1248
1249impl Default for ClippingStats {
1250    fn default() -> Self {
1251        Self {
1252            total_steps: 0,
1253            clipped_steps: 0,
1254            clipping_frequency: 0.0,
1255            avg_clipping_ratio: 1.0,
1256            avg_pre_clip_norm: 0.0,
1257            current_threshold: 1.0,
1258        }
1259    }
1260}
1261
1262/// Privacy validation results
1263#[derive(Debug, Clone)]
1264pub struct PrivacyValidation {
1265    /// Whether the run is still inside its budget.
1266    pub is_valid: bool,
1267    /// Budget snapshot.
1268    pub budget: PrivacyBudget,
1269    /// Clipping statistics.
1270    pub clipping_stats: ClippingStats,
1271    /// Warnings raised by validation.
1272    pub warnings: Vec<String>,
1273    /// Suggested adjustments.
1274    pub recommendations: Vec<String>,
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280    use crate::optimizers::SGD;
1281    use scirs2_core::ndarray::{Array1, Ix1};
1282
1283    fn test_config() -> DifferentialPrivacyConfig {
1284        DifferentialPrivacyConfig {
1285            target_epsilon: 10.0,
1286            target_delta: 1e-5,
1287            noise_multiplier: 1.1,
1288            l2_norm_clip: 1.0,
1289            batch_size: 64,
1290            dataset_size: 50_000,
1291            max_steps: 1000,
1292            ..Default::default()
1293        }
1294    }
1295
1296    fn build_optimizer(
1297        config: DifferentialPrivacyConfig,
1298    ) -> DifferentiallyPrivateOptimizer<SGD<f64>, f64, Ix1> {
1299        match DifferentiallyPrivateOptimizer::new(SGD::new(0.01), config) {
1300            Ok(optimizer) => optimizer,
1301            Err(err) => panic!("optimizer construction failed: {err}"),
1302        }
1303    }
1304
1305    #[test]
1306    fn test_dp_config_default() {
1307        let config = DifferentialPrivacyConfig::default();
1308        assert_eq!(config.target_epsilon, 1.0);
1309        assert_eq!(config.noise_multiplier, 1.1);
1310        assert!(matches!(config.noise_mechanism, NoiseMechanism::Gaussian));
1311        assert!(matches!(
1312            config.accounting_method,
1313            AccountingMethod::RenyiDP
1314        ));
1315        assert!(!config.acknowledge_aggregate_clipping);
1316        assert!(config.validate().is_ok());
1317    }
1318
1319    #[test]
1320    fn test_config_validation_rejects_unusable_parameters() {
1321        let mut config = DifferentialPrivacyConfig {
1322            noise_multiplier: 0.0,
1323            ..Default::default()
1324        };
1325        assert!(config.validate().is_err());
1326
1327        config = DifferentialPrivacyConfig {
1328            target_delta: 0.0,
1329            ..Default::default()
1330        };
1331        assert!(config.validate().is_err());
1332
1333        config = DifferentialPrivacyConfig {
1334            batch_size: 100,
1335            dataset_size: 10,
1336            ..Default::default()
1337        };
1338        assert!(config.validate().is_err());
1339    }
1340
1341    #[test]
1342    fn test_dp_optimizer_creation() {
1343        let optimizer = DifferentiallyPrivateOptimizer::<_, f64, Ix1>::new(
1344            SGD::new(0.01),
1345            DifferentialPrivacyConfig::default(),
1346        );
1347        assert!(optimizer.is_ok());
1348    }
1349
1350    #[test]
1351    fn test_unimplemented_accounting_method_is_rejected() {
1352        for method in [
1353            AccountingMethod::AdvancedComposition,
1354            AccountingMethod::ZCDP,
1355        ] {
1356            let config = DifferentialPrivacyConfig {
1357                accounting_method: method,
1358                ..Default::default()
1359            };
1360            let result =
1361                DifferentiallyPrivateOptimizer::<SGD<f64>, f64, Ix1>::new(SGD::new(0.01), config);
1362            assert!(result.is_err(), "{method:?} must not be silently accepted");
1363        }
1364    }
1365
1366    #[test]
1367    fn test_privacy_budget_tracking_starts_empty() {
1368        let optimizer = build_optimizer(test_config());
1369        let budget = match optimizer.get_privacy_budget() {
1370            Ok(budget) => budget,
1371            Err(err) => panic!("budget query failed: {err}"),
1372        };
1373
1374        assert_eq!(budget.epsilon_consumed, 0.0);
1375        assert_eq!(budget.epsilon_remaining, 10.0);
1376        assert_eq!(budget.steps_taken, 0);
1377        // Delta is a reporting parameter: nothing is consumed, and the full
1378        // reporting delta remains available.
1379        assert_eq!(budget.delta_consumed, 0.0);
1380        assert_eq!(budget.delta_remaining, 1e-5);
1381        assert!(match optimizer.has_privacy_budget() {
1382            Ok(available) => available,
1383            Err(err) => panic!("budget check failed: {err}"),
1384        });
1385    }
1386
1387    #[test]
1388    fn test_per_example_step_succeeds_and_spends_epsilon_monotonically() {
1389        let mut optimizer = build_optimizer(test_config());
1390        let params = Array1::<f64>::zeros(4);
1391        let batch: Vec<Array1<f64>> = (0..8)
1392            .map(|i| Array1::from_elem(4, 0.1 * (i as f64 + 1.0)))
1393            .collect();
1394
1395        let mut previous = 0.0;
1396        for step in 1..=25 {
1397            let updated = optimizer.dp_step_per_example(&params, &batch);
1398            assert!(updated.is_ok(), "step {step} failed: {updated:?}");
1399
1400            let epsilon = match optimizer.consumed_epsilon() {
1401                Ok(value) => value,
1402                Err(err) => panic!("accounting failed at step {step}: {err}"),
1403            };
1404            assert!(
1405                epsilon > previous,
1406                "epsilon must strictly increase: {previous} -> {epsilon} at step {step}"
1407            );
1408            previous = epsilon;
1409        }
1410
1411        assert_eq!(optimizer.accounting_segments().len(), 1);
1412    }
1413
1414    #[test]
1415    fn test_per_example_clipping_bounds_the_summed_contribution() {
1416        // A single huge gradient must not be able to move the released mean
1417        // by more than roughly C / batch_size before noise.
1418        let config = DifferentialPrivacyConfig {
1419            noise_multiplier: 0.001,
1420            l2_norm_clip: 1.0,
1421            target_epsilon: 1e9,
1422            ..test_config()
1423        };
1424        let mut optimizer = build_optimizer(config);
1425        let params = Array1::<f64>::zeros(3);
1426        let batch = vec![
1427            Array1::from_vec(vec![1e6, 0.0, 0.0]),
1428            Array1::from_vec(vec![0.0, 0.0, 0.0]),
1429            Array1::from_vec(vec![0.0, 0.0, 0.0]),
1430            Array1::from_vec(vec![0.0, 0.0, 0.0]),
1431        ];
1432
1433        let updated = match optimizer.dp_step_per_example(&params, &batch) {
1434            Ok(updated) => updated,
1435            Err(err) => panic!("step failed: {err}"),
1436        };
1437
1438        // SGD with lr = 0.01 applied to a mean gradient bounded by C / 4.
1439        let bound = 0.01 * (1.0 / 4.0) * 1.05;
1440        assert!(
1441            updated[0].abs() <= bound,
1442            "clipping failed to bound the update: {} > {bound}",
1443            updated[0].abs()
1444        );
1445    }
1446
1447    #[test]
1448    fn test_budget_exhaustion_is_enforced_before_release() {
1449        let config = DifferentialPrivacyConfig {
1450            target_epsilon: 3.0,
1451            noise_multiplier: 1.0,
1452            batch_size: 64,
1453            dataset_size: 640,
1454            max_steps: 100_000,
1455            ..test_config()
1456        };
1457        let mut optimizer = build_optimizer(config);
1458        let params = Array1::<f64>::zeros(2);
1459        let batch: Vec<Array1<f64>> = (0..64).map(|_| Array1::from_elem(2, 0.5)).collect();
1460
1461        let mut steps = 0usize;
1462        loop {
1463            match optimizer.dp_step_per_example(&params, &batch) {
1464                Ok(_) => {
1465                    steps += 1;
1466                    assert!(steps < 100_000, "budget was never enforced");
1467                }
1468                Err(OptimError::PrivacyBudgetExhausted {
1469                    consumed_epsilon,
1470                    target_epsilon,
1471                }) => {
1472                    assert!(steps > 0, "the very first step must be allowed");
1473                    assert!(consumed_epsilon <= target_epsilon);
1474                    break;
1475                }
1476                Err(other) => panic!("unexpected error: {other}"),
1477            }
1478        }
1479
1480        // Once exhausted it stays exhausted, and the reported spend never
1481        // exceeded the target.
1482        assert!(match optimizer.has_privacy_budget() {
1483            Ok(available) => !available,
1484            Err(err) => panic!("budget check failed: {err}"),
1485        });
1486        let budget = match optimizer.get_privacy_budget() {
1487            Ok(budget) => budget,
1488            Err(err) => panic!("budget query failed: {err}"),
1489        };
1490        assert!(budget.epsilon_consumed <= 3.0 + 1e-12);
1491        assert_eq!(budget.steps_taken, steps);
1492    }
1493
1494    #[test]
1495    fn test_max_steps_is_enforced() {
1496        let config = DifferentialPrivacyConfig {
1497            target_epsilon: 1e9,
1498            max_steps: 3,
1499            ..test_config()
1500        };
1501        let mut optimizer = build_optimizer(config);
1502        let params = Array1::<f64>::zeros(2);
1503        let batch = vec![Array1::from_elem(2, 0.1)];
1504
1505        for _ in 0..3 {
1506            assert!(optimizer.dp_step_per_example(&params, &batch).is_ok());
1507        }
1508        match optimizer.dp_step_per_example(&params, &batch) {
1509            Err(OptimError::PrivacyBudgetExhausted { .. }) => {}
1510            other => panic!("max_steps must be enforced, got {other:?}"),
1511        }
1512    }
1513
1514    #[test]
1515    fn test_aggregate_step_requires_explicit_acknowledgement() {
1516        let mut optimizer = build_optimizer(test_config());
1517        let params = Array1::<f64>::zeros(2);
1518        let mut gradients = Array1::from_elem(2, 0.5);
1519        match optimizer.dp_step(&params, &mut gradients) {
1520            Err(OptimError::InvalidPrivacyConfig(message)) => {
1521                assert!(message.contains("per-example"));
1522            }
1523            other => panic!("aggregate clipping must be opt-in, got {other:?}"),
1524        }
1525
1526        let config = DifferentialPrivacyConfig {
1527            acknowledge_aggregate_clipping: true,
1528            ..test_config()
1529        };
1530        let mut acknowledged = build_optimizer(config);
1531        assert!(acknowledged.dp_step(&params, &mut gradients).is_ok());
1532    }
1533
1534    #[test]
1535    fn test_unimplemented_noise_mechanisms_error() {
1536        for mechanism in [
1537            NoiseMechanism::TreeAggregation,
1538            NoiseMechanism::ImprovedComposition,
1539        ] {
1540            let config = DifferentialPrivacyConfig {
1541                noise_mechanism: mechanism,
1542                ..test_config()
1543            };
1544            let mut optimizer = build_optimizer(config);
1545            let params = Array1::<f64>::zeros(2);
1546            let batch = vec![Array1::from_elem(2, 0.1)];
1547            match optimizer.dp_step_per_example(&params, &batch) {
1548                Err(OptimError::InvalidPrivacyConfig(_)) => {}
1549                other => panic!("{mechanism:?} must not silently fall back: {other:?}"),
1550            }
1551        }
1552    }
1553
1554    #[test]
1555    fn test_laplace_path_uses_pure_epsilon_ledger() {
1556        let config = DifferentialPrivacyConfig {
1557            noise_mechanism: NoiseMechanism::Laplace,
1558            target_epsilon: 1.0,
1559            max_steps: 10,
1560            ..test_config()
1561        };
1562        let mut optimizer = build_optimizer(config);
1563        let params = Array1::<f64>::zeros(2);
1564        let batch = vec![Array1::from_elem(2, 0.1)];
1565
1566        for step in 1..=10 {
1567            assert!(
1568                optimizer.dp_step_per_example(&params, &batch).is_ok(),
1569                "step {step} should fit in the pure-epsilon budget"
1570            );
1571            let epsilon = match optimizer.consumed_epsilon() {
1572                Ok(value) => value,
1573                Err(err) => panic!("accounting failed: {err}"),
1574            };
1575            assert!((epsilon - 0.1 * step as f64).abs() < 1e-12);
1576        }
1577
1578        // Delta is exactly zero for pure epsilon-DP.
1579        let budget = match optimizer.get_privacy_budget() {
1580            Ok(budget) => budget,
1581            Err(err) => panic!("budget query failed: {err}"),
1582        };
1583        assert_eq!(budget.delta_remaining, 0.0);
1584
1585        match optimizer.dp_step_per_example(&params, &batch) {
1586            Err(OptimError::PrivacyBudgetExhausted { .. }) => {}
1587            other => panic!("pure epsilon budget must be enforced, got {other:?}"),
1588        }
1589    }
1590
1591    #[test]
1592    fn test_noise_is_actually_random_across_instances() {
1593        // Two independently constructed optimizers must not produce identical
1594        // noise: a hardcoded seed would make DP noise reproducible and thus
1595        // worthless.
1596        let params = Array1::<f64>::zeros(64);
1597        let batch = vec![Array1::<f64>::zeros(64)];
1598
1599        let mut first = build_optimizer(test_config());
1600        let mut second = build_optimizer(test_config());
1601
1602        let a = match first.dp_step_per_example(&params, &batch) {
1603            Ok(value) => value,
1604            Err(err) => panic!("step failed: {err}"),
1605        };
1606        let b = match second.dp_step_per_example(&params, &batch) {
1607            Ok(value) => value,
1608            Err(err) => panic!("step failed: {err}"),
1609        };
1610
1611        let identical = a
1612            .iter()
1613            .zip(b.iter())
1614            .all(|(x, y)| (x - y).abs() < f64::EPSILON);
1615        assert!(!identical, "two optimizers produced identical noise");
1616    }
1617
1618    #[test]
1619    fn test_adaptive_clipping_updates_threshold_and_charges_budget() {
1620        let config = DifferentialPrivacyConfig {
1621            adaptive_clipping: true,
1622            adaptive_clip_init: 1.0,
1623            adaptive_clip_lr: 0.5,
1624            adaptive_clip_target_quantile: 0.5,
1625            target_epsilon: 1e6,
1626            ..test_config()
1627        };
1628        let mut optimizer = build_optimizer(config);
1629        let params = Array1::<f64>::zeros(2);
1630        // Every gradient is far above the threshold, so the privatized
1631        // below-threshold fraction is ~0 -- well under the 0.5 target -- and
1632        // Andrew et al.'s geometric update must *raise* the threshold.
1633        let batch: Vec<Array1<f64>> = (0..16).map(|_| Array1::from_elem(2, 100.0)).collect();
1634
1635        let before = optimizer.get_clipping_threshold();
1636        let epsilon_before = match optimizer.consumed_epsilon() {
1637            Ok(value) => value,
1638            Err(err) => panic!("accounting failed: {err}"),
1639        };
1640        for _ in 0..5 {
1641            assert!(optimizer.dp_step_per_example(&params, &batch).is_ok());
1642        }
1643        let after = optimizer.get_clipping_threshold();
1644        let epsilon_after = match optimizer.consumed_epsilon() {
1645            Ok(value) => value,
1646            Err(err) => panic!("accounting failed: {err}"),
1647        };
1648
1649        assert!(after > before, "threshold should grow: {before} -> {after}");
1650        assert!(epsilon_after > epsilon_before);
1651        // Both the gradient release and the privatized quantile release are
1652        // charged, so more mechanism applications than steps are recorded.
1653        assert!(optimizer
1654            .get_audit_trail()
1655            .iter()
1656            .any(|event| event.event_type == PrivacyEventType::AdaptiveClipUpdate));
1657    }
1658
1659    #[test]
1660    fn test_secure_aggregation_flag_is_not_silently_ignored() {
1661        let config = DifferentialPrivacyConfig {
1662            secure_aggregation: true,
1663            ..test_config()
1664        };
1665        let result =
1666            DifferentiallyPrivateOptimizer::<SGD<f64>, f64, Ix1>::new(SGD::new(0.01), config);
1667        assert!(result.is_err(), "an unimplemented flag must not be ignored");
1668    }
1669
1670    #[test]
1671    fn test_dimension_mismatch_is_rejected() {
1672        let mut optimizer = build_optimizer(test_config());
1673        let params = Array1::<f64>::zeros(4);
1674        let batch = vec![Array1::<f64>::zeros(3)];
1675        assert!(optimizer.dp_step_per_example(&params, &batch).is_err());
1676        assert!(optimizer.dp_step_per_example(&params, &[]).is_err());
1677    }
1678
1679    #[test]
1680    fn test_non_finite_gradients_are_rejected() {
1681        let mut optimizer = build_optimizer(test_config());
1682        let params = Array1::<f64>::zeros(2);
1683        let batch = vec![Array1::from_vec(vec![f64::NAN, 0.0])];
1684        assert!(optimizer.dp_step_per_example(&params, &batch).is_err());
1685    }
1686
1687    #[test]
1688    fn test_presummed_path_matches_per_example_accounting() {
1689        let mut optimizer = build_optimizer(test_config());
1690        let params = Array1::<f64>::zeros(2);
1691        let summed = Array1::from_elem(2, 0.5);
1692        assert!(optimizer.dp_step_presummed(&params, &summed, 8).is_ok());
1693        assert_eq!(optimizer.accounting_segments().len(), 1);
1694        assert_eq!(optimizer.accounting_segments()[0].steps, 1);
1695        assert!(match optimizer.consumed_epsilon() {
1696            Ok(value) => value > 0.0,
1697            Err(err) => panic!("accounting failed: {err}"),
1698        });
1699        assert!(optimizer.dp_step_presummed(&params, &summed, 0).is_err());
1700    }
1701
1702    #[test]
1703    fn test_standard_normal_never_returns_non_finite() {
1704        let mut rng = thread_rng();
1705        for _ in 0..10_000 {
1706            let sample = standard_normal(&mut rng);
1707            assert!(sample.is_finite(), "Box-Muller produced {sample}");
1708            let laplace = standard_laplace(&mut rng);
1709            assert!(
1710                laplace.is_finite(),
1711                "inverse-CDF Laplace produced {laplace}"
1712            );
1713        }
1714    }
1715}