1use 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; pub mod dp_sgd;
34pub mod enhanced_audit;
35pub mod federated; pub 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
47pub use utility_analysis::{
49 AnalysisConfig, AnalysisMetadata, BudgetRecommendations, OptimalConfiguration, ParetoPoint,
50 PrivacyConfiguration, PrivacyParameterSpace, PrivacyRiskAssessment, PrivacyUtilityAnalyzer,
51 PrivacyUtilityResults, RobustnessResults, SensitivityResults, StatisticalTestResults,
52 UtilityMetric,
53};
54
55pub 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
65pub use differential_privacy::{
67 AmplificationConfig, AmplificationStats, PrivacyAmplificationAnalyzer, SubsamplingEvent,
68};
69
70pub use renyi_accountant::{DpConversion, RdpSpend, RenyiAccountant};
72
73pub use accountant::{
75 build_accountant, AccountingSegment, MomentsPrivacyAccountant, PrivacyAccountant,
76 PrivacyLedger, RenyiPrivacyAccountant,
77};
78
79pub use moment_accountant::MomentsAccountant;
84
85#[derive(Debug, Clone)]
87pub struct DifferentialPrivacyConfig {
88 pub target_epsilon: f64,
90
91 pub target_delta: f64,
94
95 pub noise_multiplier: f64,
98
99 pub l2_norm_clip: f64,
101
102 pub batch_size: usize,
104
105 pub dataset_size: usize,
107
108 pub max_steps: usize,
110
111 pub noise_mechanism: NoiseMechanism,
113
114 pub secure_aggregation: bool,
118
119 pub adaptive_clipping: bool,
122
123 pub adaptive_clip_init: f64,
125
126 pub adaptive_clip_lr: f64,
128
129 pub adaptive_clip_target_quantile: f64,
132
133 pub adaptive_clip_noise_multiplier: f64,
137
138 pub accounting_method: AccountingMethod,
140
141 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum NoiseMechanism {
261 Gaussian,
263 Laplace,
265 TreeAggregation,
269 ImprovedComposition,
272}
273
274#[derive(Debug, Clone)]
276pub struct PrivacyBudget {
277 pub epsilon_consumed: f64,
279
280 pub delta_consumed: f64,
283
284 pub epsilon_remaining: f64,
286
287 pub delta_remaining: f64,
290
291 pub steps_taken: usize,
293
294 pub accounting_method: AccountingMethod,
296
297 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum AccountingMethod {
318 MomentsAccountant,
320 RenyiDP,
322 AdvancedComposition,
325 ZCDP,
328}
329
330pub 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: O,
339
340 config: DifferentialPrivacyConfig,
342
343 accountant: Box<dyn PrivacyAccountant>,
345
346 pure_epsilon_spent: f64,
349
350 rng: scirs2_core::random::CoreRandom,
352
353 adaptive_clip_state: Option<AdaptiveClippingState>,
355
356 gradient_history: VecDeque<GradientNorms>,
358
359 audit_trail: Vec<PrivacyEvent>,
361
362 step_count: usize,
364
365 _phantom: std::marker::PhantomData<(A, D)>,
367}
368
369#[derive(Debug, Clone)]
371struct AdaptiveClippingState {
372 current_threshold: f64,
374
375 target_quantile: f64,
377
378 learning_rate: f64,
380
381 last_fraction_estimate: f64,
383
384 updates: usize,
386}
387
388#[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#[derive(Debug, Clone)]
402pub struct PrivacyEvent {
403 pub step: usize,
405 pub event_type: PrivacyEventType,
407 pub epsilon_spent: f64,
409 pub reporting_delta: f64,
411 pub noise_scale: f64,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum PrivacyEventType {
418 GradientRelease,
420 ModelUpdate,
422 ParameterQuery,
424 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn laplace_epsilon_per_step(&self) -> f64 {
892 self.config.target_epsilon / self.config.max_steps.max(1) as f64
893 }
894
895 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 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 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 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 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 pub fn accounting_segments(&self) -> &[AccountingSegment] {
984 self.accountant.segments()
985 }
986
987 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 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 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 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 pub fn get_audit_trail(&self) -> &[PrivacyEvent] {
1136 &self.audit_trail
1137 }
1138
1139 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
1208pub(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
1219pub(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#[derive(Debug, Clone)]
1234pub struct ClippingStats {
1235 pub total_steps: usize,
1237 pub clipped_steps: usize,
1239 pub clipping_frequency: f64,
1241 pub avg_clipping_ratio: f64,
1243 pub avg_pre_clip_norm: f64,
1245 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#[derive(Debug, Clone)]
1264pub struct PrivacyValidation {
1265 pub is_valid: bool,
1267 pub budget: PrivacyBudget,
1269 pub clipping_stats: ClippingStats,
1271 pub warnings: Vec<String>,
1273 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 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(¶ms, &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 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(¶ms, &batch) {
1434 Ok(updated) => updated,
1435 Err(err) => panic!("step failed: {err}"),
1436 };
1437
1438 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(¶ms, &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 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(¶ms, &batch).is_ok());
1507 }
1508 match optimizer.dp_step_per_example(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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 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(¶ms, &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 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(¶ms, &batch) {
1603 Ok(value) => value,
1604 Err(err) => panic!("step failed: {err}"),
1605 };
1606 let b = match second.dp_step_per_example(¶ms, &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 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(¶ms, &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 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(¶ms, &batch).is_err());
1676 assert!(optimizer.dp_step_per_example(¶ms, &[]).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(¶ms, &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(¶ms, &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(¶ms, &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}