Skip to main content

optirs_core/privacy/
dp_sgd.rs

1// Differentially Private Stochastic Gradient Descent (DP-SGD)
2//
3// This module implements DP-SGD with per-example gradient clipping, adaptive
4// clipping via a differentially private quantile estimate, calibrated noise
5// and privacy budget tracking.
6//
7// # What makes this private
8//
9// DP-SGD bounds the influence of a single training example by clipping *each
10// per-example gradient* to an L2 norm `C`, summing the clipped gradients, and
11// adding `N(0, sigma^2 C^2)` to the **sum** before dividing by the batch
12// size. Clipping an already-aggregated batch gradient bounds nothing about a
13// single example, which is why [`DPSGDOptimizer::dp_step`] requires an
14// explicit acknowledgement and why
15// [`DPSGDOptimizer::dp_step_per_example`] is the entry point to prefer.
16//
17// # Accounting
18//
19// Privacy spend is recorded in an append-only ledger of
20// `(noise_multiplier, sampling_probability, steps)` segments held by the
21// configured [`PrivacyAccountant`]. Changing the batch size or the noise
22// multiplier mid-training opens a new segment; it never rewrites the cost of
23// steps already taken.
24
25// `!(x > 0)` is used deliberately over `x <= 0` in privacy-parameter validation:
26// it rejects NaN (whose comparisons are all `false`), which `x <= 0` would let
27// through. A NaN epsilon silently voiding the DP guarantee is exactly the failure
28// this spelling prevents.
29#![allow(clippy::neg_cmp_op_on_partial_ord)]
30
31use scirs2_core::ndarray::{Array, ArrayBase, Data, DataMut, Dimension, Zip};
32use scirs2_core::numeric::Float;
33use std::cmp::Ordering;
34use std::collections::{HashMap, VecDeque};
35
36use super::accountant::{build_accountant, AccountingSegment, PrivacyAccountant};
37use super::{
38    standard_laplace, standard_normal, DifferentialPrivacyConfig, NoiseMechanism, PrivacyBudget,
39};
40use crate::error::{OptimError, Result};
41use crate::optimizers::Optimizer;
42
43/// DP-SGD optimizer with privacy guarantees
44pub struct DPSGDOptimizer<O, A, D>
45where
46    A: Float
47        + Send
48        + Sync
49        + scirs2_core::ndarray::ScalarOperand
50        + std::fmt::Debug
51        + Default
52        + Clone
53        + std::iter::Sum,
54    D: scirs2_core::ndarray::Dimension,
55    O: Optimizer<A, D>,
56{
57    /// Base optimizer (SGD, Adam, etc.)
58    baseoptimizer: O,
59
60    /// Privacy configuration
61    config: DifferentialPrivacyConfig,
62
63    /// Privacy accountant selected from `config.accounting_method`
64    accountant: Box<dyn PrivacyAccountant>,
65
66    /// Pure epsilon-DP ledger used by the Laplace mechanism
67    pure_epsilon_spent: f64,
68
69    /// Random number generator for noise (seeded from OS entropy)
70    rng: scirs2_core::random::CoreRandom,
71
72    /// Adaptive clipping state
73    adaptive_clipping: Option<AdaptiveClippingState>,
74
75    /// Privacy consumption history
76    privacy_budget: PrivacyBudgetTracker,
77
78    /// Gradient statistics
79    gradient_stats: GradientStatistics<A>,
80
81    /// Noise calibration history
82    noise_calibrator: NoiseCalibrator<A>,
83
84    /// Current step count
85    step_count: usize,
86
87    /// Batch size used for the most recent step
88    current_batch_size: usize,
89
90    /// Phantom data for unused type parameter
91    _phantom: std::marker::PhantomData<D>,
92}
93
94/// Adaptive clipping state for DP-SGD
95#[derive(Debug, Clone)]
96struct AdaptiveClippingState {
97    /// Current clipping threshold
98    current_threshold: f64,
99
100    /// Target quantile (fraction of gradients that should stay unclipped)
101    target_quantile: f64,
102
103    /// Geometric adaptation rate
104    adaptationlr: f64,
105
106    /// History of (privatized) below-threshold fractions
107    fraction_history: VecDeque<f64>,
108
109    /// Non-private quantile estimator over observed norms, used only for
110    /// diagnostics that never leave the process
111    quantile_estimator: QuantileEstimator,
112
113    /// Number of threshold updates performed
114    updates: usize,
115}
116
117/// P-squared quantile estimator (Jain & Chlamtac, 1985).
118#[derive(Debug, Clone)]
119struct QuantileEstimator {
120    /// P-squared marker state
121    p2_state: P2AlgorithmState,
122
123    /// Exponential moving average, used before five samples are available
124    ema: f64,
125
126    /// EMA decay factor
127    ema_decay: f64,
128}
129
130/// P-squared algorithm state.
131///
132/// `heights[i]` are the marker heights `q_i`, `positions[i]` the marker
133/// positions `n_i`, `desired[i]` the desired positions `n'_i` and
134/// `increments[i]` the per-observation increments `dn'_i`.
135#[derive(Debug, Clone)]
136struct P2AlgorithmState {
137    heights: [f64; 5],
138    positions: [f64; 5],
139    desired: [f64; 5],
140    increments: [f64; 5],
141    count: usize,
142    initial: Vec<f64>,
143}
144
145/// Privacy budget tracker
146#[derive(Debug, Clone)]
147struct PrivacyBudgetTracker {
148    /// Total epsilon consumed (as reported by the accountant)
149    epsilon_consumed: f64,
150
151    /// Target epsilon
152    target_epsilon: f64,
153
154    /// Delta at which epsilon is reported
155    reporting_delta: f64,
156
157    /// Privacy consumption history
158    consumption_history: Vec<PrivacyConsumption>,
159}
160
161/// Privacy consumption record
162#[derive(Debug, Clone)]
163pub struct PrivacyConsumption {
164    /// Step at which the release happened.
165    pub step: usize,
166    /// Cumulative epsilon after the release.
167    pub epsilon_spent: f64,
168    /// Delta the epsilon is reported at.
169    pub reporting_delta: f64,
170    /// Batch size used for the release.
171    pub batchsize: usize,
172    /// Noise multiplier used for the release.
173    pub noise_multiplier: f64,
174}
175
176/// Gradient statistics for DP-SGD
177#[derive(Debug, Clone)]
178struct GradientStatistics<A: Float + Default + Clone + std::iter::Sum> {
179    /// Recent gradient norms
180    norm_history: VecDeque<A>,
181
182    /// Exponential moving average of the clipping indicator
183    clipping_frequency: f64,
184
185    /// EMA rate for the clipping frequency
186    clipping_frequency_rate: f64,
187
188    /// Average gradient norm
189    avg_norm: A,
190
191    /// Std deviation of gradient norms
192    std_norm: A,
193
194    /// Percentile statistics
195    percentiles: HashMap<String, A>,
196
197    /// Maximum history size
198    max_history_size: usize,
199}
200
201/// Noise calibration for different mechanisms
202#[derive(Debug, Clone)]
203struct NoiseCalibrator<A: Float> {
204    /// Current noise multiplier
205    noise_multiplier: A,
206
207    /// Base noise scale
208    base_noise_scale: A,
209
210    /// Noise mechanism
211    mechanism: NoiseMechanism,
212
213    /// Calibration history
214    calibration_history: Vec<NoiseCalibration<A>>,
215}
216
217/// Noise calibration record
218#[derive(Debug, Clone)]
219pub struct NoiseCalibration<A: Float> {
220    /// Step of the record.
221    pub step: usize,
222    /// Standard deviation of the noise added.
223    pub noise_scale: A,
224    /// Mean pre-clipping gradient norm.
225    pub gradientnorm: A,
226    /// Clipping threshold in force.
227    pub clipping_threshold: A,
228    /// Cumulative epsilon after the release.
229    pub privacy_cost: A,
230}
231
232impl<O, A, D> DPSGDOptimizer<O, A, D>
233where
234    A: Float
235        + Default
236        + Clone
237        + Send
238        + Sync
239        + scirs2_core::ndarray::ScalarOperand
240        + std::fmt::Debug
241        + std::iter::Sum
242        + std::ops::AddAssign,
243    D: scirs2_core::ndarray::Dimension,
244    O: Optimizer<A, D> + Send + Sync,
245{
246    /// Create a new DP-SGD optimizer.
247    ///
248    /// The configuration is validated and the accountant named by
249    /// `config.accounting_method` is constructed; an unimplemented accounting
250    /// method or noise mechanism is an error rather than a silent fallback.
251    pub fn new(baseoptimizer: O, config: DifferentialPrivacyConfig) -> Result<Self> {
252        config.validate()?;
253
254        if config.secure_aggregation {
255            return Err(OptimError::InvalidPrivacyConfig(
256                "secure_aggregation is not wired into DPSGDOptimizer; use \
257                 privacy::secure_aggregation::SecureAggregator explicitly rather than enabling \
258                 a flag that would otherwise be ignored"
259                    .to_string(),
260            ));
261        }
262
263        let accountant = build_accountant(
264            config.accounting_method,
265            config.noise_multiplier,
266            config.target_delta,
267            config.batch_size,
268            config.dataset_size,
269        )?;
270
271        let rng = scirs2_core::random::thread_rng();
272
273        let adaptive_clipping = if config.adaptive_clipping {
274            Some(AdaptiveClippingState::new(
275                config.adaptive_clip_init,
276                config.adaptive_clip_lr,
277                config.adaptive_clip_target_quantile,
278            )?)
279        } else {
280            None
281        };
282
283        let privacy_budget = PrivacyBudgetTracker::new(&config);
284        let gradient_stats = GradientStatistics::new();
285        let noise_calibrator = NoiseCalibrator::new(&config)?;
286
287        let batchsize = config.batch_size;
288        Ok(Self {
289            baseoptimizer,
290            config,
291            accountant,
292            pure_epsilon_spent: 0.0,
293            rng,
294            adaptive_clipping,
295            privacy_budget,
296            gradient_stats,
297            noise_calibrator,
298            step_count: 0,
299            current_batch_size: batchsize,
300            _phantom: std::marker::PhantomData,
301        })
302    }
303
304    /// Perform a DP-SGD step from **per-example** gradients.
305    ///
306    /// Each gradient is clipped to the current threshold `C`, the clipped
307    /// gradients are summed, `N(0, sigma^2 C^2)` is added to the sum, and the
308    /// result is divided by the batch size. The privacy budget is enforced
309    /// before anything is released.
310    pub fn dp_step_per_example(
311        &mut self,
312        params: &Array<A, D>,
313        per_example_gradients: &[Array<A, D>],
314    ) -> Result<Array<A, D>> {
315        if per_example_gradients.is_empty() {
316            return Err(OptimError::InvalidConfig(
317                "dp_step_per_example requires at least one per-example gradient".to_string(),
318            ));
319        }
320        for gradient in per_example_gradients {
321            if gradient.raw_dim() != params.raw_dim() {
322                return Err(OptimError::DimensionMismatch(format!(
323                    "per-example gradient shape {:?} does not match parameter shape {:?}",
324                    gradient.shape(),
325                    params.shape()
326                )));
327            }
328        }
329
330        let batchsize = per_example_gradients.len();
331        self.enforce_budget(batchsize)?;
332
333        let clipping_threshold = self.get_clipping_threshold();
334        let mut summed: Array<A, D> = Array::zeros(params.raw_dim());
335        let mut norm_sum = 0.0;
336        let mut below_threshold = 0usize;
337        let mut clipped_count = 0usize;
338
339        for gradient in per_example_gradients {
340            let norm = self.compute_gradient_norm_f64(gradient)?;
341            norm_sum += norm;
342
343            let scale = if norm > clipping_threshold && norm > 0.0 {
344                clipped_count += 1;
345                clipping_threshold / norm
346            } else {
347                below_threshold += 1;
348                1.0
349            };
350
351            let scale_a = A::from(scale).ok_or_else(|| {
352                OptimError::InvalidConfig("failed to convert clipping scale".to_string())
353            })?;
354            Zip::from(&mut summed)
355                .and(gradient)
356                .for_each(|acc, &g| *acc += g * scale_a);
357        }
358
359        self.add_noise(&mut summed, clipping_threshold)?;
360
361        let batch_a = A::from(batchsize as f64)
362            .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
363        summed.mapv_inplace(|x| x / batch_a);
364
365        let mean_norm = norm_sum / batchsize as f64;
366        self.finish_step(batchsize, mean_norm, clipping_threshold, clipped_count)?;
367
368        if self.config.adaptive_clipping {
369            self.update_adaptive_clipping(below_threshold, batchsize)?;
370        }
371
372        self.baseoptimizer.step(params, &summed)
373    }
374
375    /// Perform a DP-SGD step from a pre-clipped **sum** of per-example
376    /// gradients.
377    ///
378    /// The caller asserts that each summed gradient was clipped to
379    /// `config.l2_norm_clip`.
380    pub fn dp_step_presummed(
381        &mut self,
382        params: &Array<A, D>,
383        summed_clipped_gradients: &Array<A, D>,
384        batchsize: usize,
385    ) -> Result<Array<A, D>> {
386        if batchsize == 0 {
387            return Err(OptimError::InvalidConfig(
388                "batchsize must be positive".to_string(),
389            ));
390        }
391        if summed_clipped_gradients.raw_dim() != params.raw_dim() {
392            return Err(OptimError::DimensionMismatch(format!(
393                "gradient shape {:?} does not match parameter shape {:?}",
394                summed_clipped_gradients.shape(),
395                params.shape()
396            )));
397        }
398
399        self.enforce_budget(batchsize)?;
400
401        let clipping_threshold = self.get_clipping_threshold();
402        let mut summed = summed_clipped_gradients.clone();
403        self.add_noise(&mut summed, clipping_threshold)?;
404
405        let batch_a = A::from(batchsize as f64)
406            .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
407        summed.mapv_inplace(|x| x / batch_a);
408
409        let mean_norm =
410            self.compute_gradient_norm_f64(summed_clipped_gradients)? / batchsize as f64;
411        self.finish_step(batchsize, mean_norm, clipping_threshold, 0)?;
412
413        self.baseoptimizer.step(params, &summed)
414    }
415
416    /// Perform a DP-SGD step that clips an **already aggregated** gradient.
417    ///
418    /// # This does not provide per-example differential privacy
419    ///
420    /// Clipping the batch gradient bounds the influence of the whole batch,
421    /// not of any single example. The step is therefore accounted with
422    /// sampling probability `q = 1` (batch-level adjacency, no subsampling
423    /// amplification) and requires
424    /// [`DifferentialPrivacyConfig::acknowledge_aggregate_clipping`].
425    /// Prefer [`Self::dp_step_per_example`].
426    pub fn dp_step(
427        &mut self,
428        params: &Array<A, D>,
429        gradients: &mut Array<A, D>,
430        batchsize: usize,
431    ) -> Result<Array<A, D>> {
432        if !self.config.acknowledge_aggregate_clipping {
433            return Err(OptimError::InvalidPrivacyConfig(
434                "dp_step clips an already-aggregated gradient and therefore does NOT provide \
435                 per-example differential privacy. Use dp_step_per_example (or \
436                 dp_step_presummed), or set acknowledge_aggregate_clipping = true to accept \
437                 batch-level adjacency semantics"
438                    .to_string(),
439            ));
440        }
441        if batchsize == 0 {
442            return Err(OptimError::InvalidConfig(
443                "batchsize must be positive".to_string(),
444            ));
445        }
446
447        // Batch-level adjacency: account without subsampling amplification.
448        self.enforce_budget(self.config.dataset_size)?;
449
450        let pre_clip_norm = self.compute_gradient_norm_f64(gradients)?;
451        let clipping_threshold = self.get_clipping_threshold();
452        let clipped = if pre_clip_norm > clipping_threshold && pre_clip_norm > 0.0 {
453            let scale = A::from(clipping_threshold / pre_clip_norm).ok_or_else(|| {
454                OptimError::InvalidConfig("failed to convert clipping scale".to_string())
455            })?;
456            gradients.mapv_inplace(|g| g * scale);
457            1
458        } else {
459            0
460        };
461
462        self.add_noise(gradients, clipping_threshold)?;
463        self.finish_step(
464            self.config.dataset_size,
465            pre_clip_norm,
466            clipping_threshold,
467            clipped,
468        )?;
469
470        self.baseoptimizer.step(params, gradients)
471    }
472
473    /// Refuse the step unless it fits inside the budget and the step cap.
474    fn enforce_budget(&mut self, batchsize: usize) -> Result<()> {
475        if self.step_count >= self.config.max_steps {
476            return Err(OptimError::PrivacyBudgetExhausted {
477                consumed_epsilon: self.consumed_epsilon()?,
478                target_epsilon: self.config.target_epsilon,
479            });
480        }
481
482        let projected = self.projected_epsilon(batchsize)?;
483        if projected > self.config.target_epsilon {
484            return Err(OptimError::PrivacyBudgetExhausted {
485                consumed_epsilon: self.consumed_epsilon()?,
486                target_epsilon: self.config.target_epsilon,
487            });
488        }
489        Ok(())
490    }
491
492    /// Record a released step: accounting, statistics, calibration history.
493    fn finish_step(
494        &mut self,
495        batchsize: usize,
496        mean_pre_clip_norm: f64,
497        clipping_threshold: f64,
498        clipped_count: usize,
499    ) -> Result<()> {
500        self.step_count += 1;
501        self.current_batch_size = batchsize;
502
503        match self.config.noise_mechanism {
504            NoiseMechanism::Gaussian => {
505                let q = self.sampling_probability(batchsize);
506                self.accountant
507                    .compose_subsampled_gaussian(self.config.noise_multiplier, q, 1)?;
508            }
509            NoiseMechanism::Laplace => {
510                self.pure_epsilon_spent += self.laplace_epsilon_per_step();
511            }
512            other => {
513                return Err(OptimError::InvalidPrivacyConfig(format!(
514                    "noise mechanism {other:?} is not implemented for DP-SGD"
515                )));
516            }
517        }
518
519        let epsilon_spent = self.consumed_epsilon()?;
520        self.privacy_budget.record(
521            self.step_count,
522            epsilon_spent,
523            self.reporting_delta(),
524            batchsize,
525            self.config.noise_multiplier,
526        );
527
528        let norm_a = A::from(mean_pre_clip_norm).ok_or_else(|| {
529            OptimError::InvalidConfig("failed to convert gradient norm".to_string())
530        })?;
531        self.gradient_stats.update_norm(norm_a)?;
532        self.gradient_stats
533            .update_clipping(clipped_count * 2 > batchsize.max(1));
534
535        if let Some(ref mut state) = self.adaptive_clipping {
536            state.observe_norm(mean_pre_clip_norm);
537        }
538
539        let threshold_a = A::from(clipping_threshold).ok_or_else(|| {
540            OptimError::InvalidConfig("failed to convert clipping threshold".to_string())
541        })?;
542        let cost_a = A::from(if epsilon_spent.is_finite() {
543            epsilon_spent
544        } else {
545            f64::MAX
546        })
547        .ok_or_else(|| OptimError::InvalidConfig("failed to convert privacy cost".to_string()))?;
548        self.noise_calibrator
549            .update_calibration(self.step_count, norm_a, threshold_a, cost_a);
550
551        Ok(())
552    }
553
554    /// Differentially private clipping-threshold update
555    /// (Andrew et al. 2021).
556    ///
557    /// The number of per-example gradients below the current threshold is a
558    /// counting query with sensitivity 1; it is released with Gaussian noise
559    /// and charged to the budget. The threshold is then updated
560    /// geometrically towards the target quantile.
561    fn update_adaptive_clipping(&mut self, below_threshold: usize, batchsize: usize) -> Result<()> {
562        if batchsize == 0 {
563            return Ok(());
564        }
565
566        let sigma_count = self.config.adaptive_clip_noise_multiplier;
567        let noisy_below = below_threshold as f64 + sigma_count * standard_normal(&mut self.rng);
568        let fraction = (noisy_below / batchsize as f64).clamp(0.0, 1.0);
569
570        if let Some(ref mut state) = self.adaptive_clipping {
571            state.update_threshold(fraction);
572        } else {
573            return Ok(());
574        }
575
576        match self.config.noise_mechanism {
577            NoiseMechanism::Gaussian => {
578                let q = self.sampling_probability(batchsize);
579                self.accountant
580                    .compose_subsampled_gaussian(sigma_count, q, 1)?;
581            }
582            NoiseMechanism::Laplace => {
583                self.pure_epsilon_spent += self.laplace_epsilon_per_step();
584            }
585            other => {
586                return Err(OptimError::InvalidPrivacyConfig(format!(
587                    "noise mechanism {other:?} is not implemented for DP-SGD"
588                )));
589            }
590        }
591
592        let epsilon_spent = self.consumed_epsilon()?;
593        self.privacy_budget.record(
594            self.step_count,
595            epsilon_spent,
596            self.reporting_delta(),
597            batchsize,
598            sigma_count,
599        );
600
601        Ok(())
602    }
603
604    /// Sampling probability for a batch of the given size.
605    fn sampling_probability(&self, batchsize: usize) -> f64 {
606        if self.config.dataset_size == 0 {
607            0.0
608        } else {
609            (batchsize as f64 / self.config.dataset_size as f64).min(1.0)
610        }
611    }
612
613    /// Per-step epsilon allowance of the pure epsilon-DP (Laplace) path.
614    fn laplace_epsilon_per_step(&self) -> f64 {
615        self.config.target_epsilon / self.config.max_steps.max(1) as f64
616    }
617
618    /// Delta at which the reported epsilon holds (0 for pure epsilon-DP).
619    fn reporting_delta(&self) -> f64 {
620        match self.config.noise_mechanism {
621            NoiseMechanism::Laplace => 0.0,
622            _ => self.config.target_delta,
623        }
624    }
625
626    /// Epsilon consumed so far. Accounting errors are propagated, never
627    /// swallowed into a zero spend.
628    pub fn consumed_epsilon(&self) -> Result<f64> {
629        match self.config.noise_mechanism {
630            NoiseMechanism::Laplace => Ok(self.pure_epsilon_spent),
631            _ => {
632                let (epsilon, _) = self.accountant.privacy_spent(self.config.target_delta)?;
633                Ok(epsilon)
634            }
635        }
636    }
637
638    /// Epsilon after one more step with the given batch size.
639    fn projected_epsilon(&self, batchsize: usize) -> Result<f64> {
640        match self.config.noise_mechanism {
641            NoiseMechanism::Laplace => {
642                Ok(self.pure_epsilon_spent + self.laplace_epsilon_per_step())
643            }
644            NoiseMechanism::Gaussian => {
645                let q = self.sampling_probability(batchsize);
646                let (epsilon, _) = self.accountant.projected_privacy_spent(
647                    self.config.noise_multiplier,
648                    q,
649                    1,
650                    self.config.target_delta,
651                )?;
652                Ok(epsilon)
653            }
654            other => Err(OptimError::InvalidPrivacyConfig(format!(
655                "noise mechanism {other:?} is not implemented for DP-SGD"
656            ))),
657        }
658    }
659
660    /// Whether at least one more step fits inside the budget.
661    pub fn has_privacy_budget(&self) -> Result<bool> {
662        if self.step_count >= self.config.max_steps {
663            return Ok(false);
664        }
665        Ok(self.projected_epsilon(self.current_batch_size)? <= self.config.target_epsilon)
666    }
667
668    /// Current privacy budget status.
669    pub fn get_privacy_budget(&self) -> Result<PrivacyBudget> {
670        let epsilon_consumed = self.consumed_epsilon()?;
671        Ok(PrivacyBudget {
672            epsilon_consumed,
673            // Delta is a reporting parameter, never an additive spend.
674            delta_consumed: 0.0,
675            epsilon_remaining: (self.config.target_epsilon - epsilon_consumed).max(0.0),
676            delta_remaining: self.reporting_delta(),
677            steps_taken: self.step_count,
678            accounting_method: self.config.accounting_method,
679            estimated_steps_remaining: self.estimate_remaining_steps(epsilon_consumed),
680        })
681    }
682
683    /// Immutable view of the append-only accounting ledger.
684    pub fn accounting_segments(&self) -> &[AccountingSegment] {
685        self.accountant.segments()
686    }
687
688    /// Get adaptive clipping statistics
689    pub fn get_clipping_stats(&self) -> AdaptiveClippingStats {
690        AdaptiveClippingStats {
691            current_threshold: self.get_clipping_threshold(),
692            target_quantile: self
693                .adaptive_clipping
694                .as_ref()
695                .map(|ac| ac.target_quantile)
696                .unwrap_or(self.config.adaptive_clip_target_quantile),
697            clipping_frequency: self.gradient_stats.clipping_frequency,
698            avg_gradient_norm: self.gradient_stats.avg_norm.to_f64().unwrap_or(0.0),
699            std_gradient_norm: self.gradient_stats.std_norm.to_f64().unwrap_or(0.0),
700            adaptation_rate: self
701                .adaptive_clipping
702                .as_ref()
703                .map(|ac| ac.adaptationlr)
704                .unwrap_or(0.0),
705            quantile_estimate: self
706                .adaptive_clipping
707                .as_ref()
708                .map(|ac| ac.quantile_estimator.estimate())
709                .unwrap_or(0.0),
710            threshold_updates: self
711                .adaptive_clipping
712                .as_ref()
713                .map(|ac| ac.updates)
714                .unwrap_or(0),
715        }
716    }
717
718    /// Set the batch size used by subsequent steps.
719    ///
720    /// This opens a new accounting segment from the next step onwards; steps
721    /// already taken keep the cost they were charged. Rebuilding the
722    /// accountant here (as an earlier revision did) retroactively rewrote
723    /// privacy that had already been spent.
724    pub fn set_batch_size(&mut self, batchsize: usize) -> Result<()> {
725        if batchsize == 0 || batchsize > self.config.dataset_size {
726            return Err(OptimError::InvalidConfig(format!(
727                "batch size must be in 1..={}, got {batchsize}",
728                self.config.dataset_size
729            )));
730        }
731        self.current_batch_size = batchsize;
732        self.config.batch_size = batchsize;
733        Ok(())
734    }
735
736    /// Update the privacy configuration mid-training.
737    ///
738    /// The privacy *target* (epsilon, delta), the dataset size, the noise
739    /// mechanism and the accounting method are immutable once training has
740    /// started: changing them would re-interpret privacy that has already
741    /// been spent. Operational parameters (noise multiplier, clipping norm,
742    /// batch size, step cap) may change and simply open a new ledger segment.
743    pub fn update_privacy_config(&mut self, newconfig: DifferentialPrivacyConfig) -> Result<()> {
744        newconfig.validate()?;
745
746        if newconfig.target_epsilon != self.config.target_epsilon
747            || newconfig.target_delta != self.config.target_delta
748        {
749            return Err(OptimError::InvalidConfig(
750                "the privacy target (epsilon, delta) cannot be changed mid-training: already \
751                 spent privacy would be re-interpreted under a different target"
752                    .to_string(),
753            ));
754        }
755        if newconfig.dataset_size != self.config.dataset_size {
756            return Err(OptimError::InvalidConfig(
757                "dataset_size cannot be changed mid-training: it defines the sampling \
758                 probability of steps already accounted"
759                    .to_string(),
760            ));
761        }
762        if newconfig.noise_mechanism != self.config.noise_mechanism
763            || newconfig.accounting_method != self.config.accounting_method
764        {
765            return Err(OptimError::InvalidConfig(
766                "the noise mechanism and accounting method cannot be changed mid-training"
767                    .to_string(),
768            ));
769        }
770
771        self.privacy_budget.target_epsilon = newconfig.target_epsilon;
772        self.privacy_budget.reporting_delta = newconfig.target_delta;
773        self.noise_calibrator.noise_multiplier =
774            A::from(newconfig.noise_multiplier).ok_or_else(|| {
775                OptimError::InvalidConfig("failed to convert noise multiplier".to_string())
776            })?;
777        self.config = newconfig;
778        Ok(())
779    }
780
781    /// Compute an L2 gradient norm, rejecting non-finite values.
782    fn compute_gradient_norm_f64<S, DIM>(&self, gradients: &ArrayBase<S, DIM>) -> Result<f64>
783    where
784        S: Data<Elem = A>,
785        DIM: Dimension,
786    {
787        let mut sum_squares = 0.0f64;
788        for &value in gradients.iter() {
789            let v = value.to_f64().unwrap_or(f64::NAN);
790            if !v.is_finite() {
791                return Err(OptimError::InvalidConfig(
792                    "gradient contains a non-finite value; clipping cannot bound its sensitivity"
793                        .to_string(),
794                ));
795            }
796            sum_squares += v * v;
797        }
798        Ok(sum_squares.sqrt())
799    }
800
801    /// Current clipping threshold.
802    pub fn get_clipping_threshold(&self) -> f64 {
803        if let Some(ref adaptive_state) = self.adaptive_clipping {
804            adaptive_state.current_threshold
805        } else {
806            self.config.l2_norm_clip
807        }
808    }
809
810    /// Add calibrated noise to a (summed) gradient container.
811    ///
812    /// * Gaussian: `N(0, (sigma * C)^2)`.
813    /// * Laplace: `Lap(b)` with `b = sensitivity / epsilon_step`, the L1
814    ///   sensitivity bounded by `sqrt(d) * C`. Sampling a Gaussian and
815    ///   labelling it Laplace (as an earlier revision did) provides none of
816    ///   the pure epsilon-DP guarantee the mechanism is chosen for.
817    /// * Any other mechanism is an error.
818    fn add_noise<S, DIM>(
819        &mut self,
820        gradients: &mut ArrayBase<S, DIM>,
821        clipping_threshold: f64,
822    ) -> Result<()>
823    where
824        S: DataMut<Elem = A>,
825        DIM: Dimension,
826    {
827        match self.config.noise_mechanism {
828            NoiseMechanism::Gaussian => {
829                let sigma = self.config.noise_multiplier * clipping_threshold;
830                if !sigma.is_finite() || sigma <= 0.0 {
831                    return Err(OptimError::InvalidConfig(format!(
832                        "noise scale must be positive and finite, got {sigma}"
833                    )));
834                }
835                let mut failed = false;
836                gradients.mapv_inplace(|g| {
837                    let sample = standard_normal(&mut self.rng) * sigma;
838                    match A::from(sample) {
839                        Some(noise) => g + noise,
840                        None => {
841                            failed = true;
842                            g
843                        }
844                    }
845                });
846                if failed {
847                    return Err(OptimError::InvalidConfig(
848                        "failed to convert Gaussian noise sample".to_string(),
849                    ));
850                }
851            }
852            NoiseMechanism::Laplace => {
853                let dimension = gradients.len().max(1) as f64;
854                let l1_sensitivity = clipping_threshold * dimension.sqrt();
855                let epsilon_step = self.laplace_epsilon_per_step();
856                if !(epsilon_step > 0.0) {
857                    return Err(OptimError::InvalidPrivacyConfig(
858                        "Laplace mechanism requires a positive per-step epsilon".to_string(),
859                    ));
860                }
861                let scale = l1_sensitivity / epsilon_step;
862                if !scale.is_finite() || scale <= 0.0 {
863                    return Err(OptimError::InvalidConfig(format!(
864                        "Laplace scale must be positive and finite, got {scale}"
865                    )));
866                }
867                let mut failed = false;
868                gradients.mapv_inplace(|g| {
869                    let sample = standard_laplace(&mut self.rng) * scale;
870                    match A::from(sample) {
871                        Some(noise) => g + noise,
872                        None => {
873                            failed = true;
874                            g
875                        }
876                    }
877                });
878                if failed {
879                    return Err(OptimError::InvalidConfig(
880                        "failed to convert Laplace noise sample".to_string(),
881                    ));
882                }
883            }
884            other => {
885                return Err(OptimError::InvalidPrivacyConfig(format!(
886                    "noise mechanism {other:?} is not implemented for DP-SGD; use Gaussian or \
887                     Laplace"
888                )));
889            }
890        }
891
892        Ok(())
893    }
894
895    /// Estimate remaining steps before budget exhaustion.
896    fn estimate_remaining_steps(&self, epsilon_consumed: f64) -> usize {
897        let by_steps = self.config.max_steps.saturating_sub(self.step_count);
898        if self.step_count == 0 || !epsilon_consumed.is_finite() || epsilon_consumed <= 0.0 {
899            return by_steps;
900        }
901
902        let epsilon_per_step = epsilon_consumed / self.step_count as f64;
903        let remaining_epsilon = (self.config.target_epsilon - epsilon_consumed).max(0.0);
904        let by_budget = (remaining_epsilon / epsilon_per_step) as usize;
905        by_budget.min(by_steps)
906    }
907
908    /// Get privacy accounting details
909    pub fn get_privacy_accounting_details(&self) -> PrivacyAccountingDetails {
910        PrivacyAccountingDetails {
911            accounting_segments: self.accountant.segments().to_vec(),
912            privacy_consumption_history: self.privacy_budget.consumption_history.clone(),
913            gradient_statistics: GradientStatsSnapshot {
914                avg_norm: self.gradient_stats.avg_norm.to_f64().unwrap_or(0.0),
915                std_norm: self.gradient_stats.std_norm.to_f64().unwrap_or(0.0),
916                clipping_frequency: self.gradient_stats.clipping_frequency,
917                percentiles: self
918                    .gradient_stats
919                    .percentiles
920                    .iter()
921                    .map(|(k, v)| (k.clone(), v.to_f64().unwrap_or(0.0)))
922                    .collect(),
923            },
924            noise_calibration_history: self
925                .noise_calibrator
926                .calibration_history
927                .iter()
928                .map(|entry| NoiseCalibration {
929                    step: entry.step,
930                    noise_scale: entry.noise_scale.to_f64().unwrap_or(0.0),
931                    gradientnorm: entry.gradientnorm.to_f64().unwrap_or(0.0),
932                    clipping_threshold: entry.clipping_threshold.to_f64().unwrap_or(0.0),
933                    privacy_cost: entry.privacy_cost.to_f64().unwrap_or(0.0),
934                })
935                .collect(),
936        }
937    }
938
939    /// Validate the DP-SGD configuration and report advisory warnings.
940    pub fn validate_configuration(&self) -> Result<ConfigurationValidation> {
941        let mut warnings = Vec::new();
942        let mut errors = Vec::new();
943
944        if let Err(err) = self.config.validate() {
945            errors.push(err.to_string());
946        }
947
948        if self.config.noise_multiplier < 0.5 {
949            warnings.push(
950                "Noise multipliers below 0.5 give very weak guarantees for DP-SGD".to_string(),
951            );
952        }
953        if self.config.noise_multiplier > 10.0 {
954            warnings.push("Very high noise multiplier may severely impact utility".to_string());
955        }
956        if self.config.l2_norm_clip < 0.01 {
957            warnings.push("Very low clipping threshold may destroy gradient signal".to_string());
958        }
959        if self.config.l2_norm_clip > 100.0 {
960            warnings.push(
961                "Very high clipping threshold may not provide effective clipping".to_string(),
962            );
963        }
964        if self.config.batch_size < 16 {
965            warnings.push("Small batch size reduces privacy amplification benefits".to_string());
966        }
967        if self.config.dataset_size < 1000 {
968            warnings
969                .push("Small dataset limits the achievable privacy-utility tradeoff".to_string());
970        }
971        if self.config.target_epsilon > 10.0 {
972            warnings.push("Large epsilon provides a weak privacy guarantee".to_string());
973        }
974        if self.config.target_delta > 1.0 / self.config.dataset_size as f64 {
975            errors.push("Delta should be much smaller than 1/n".to_string());
976        }
977
978        Ok(ConfigurationValidation {
979            is_valid: errors.is_empty(),
980            warnings,
981            errors,
982            recommended_adjustments: self.generate_recommendations(),
983        })
984    }
985
986    fn generate_recommendations(&self) -> Vec<String> {
987        let mut recommendations = Vec::new();
988
989        if self.gradient_stats.clipping_frequency > 0.8 {
990            recommendations.push(
991                "Consider increasing the clipping threshold - high clipping frequency detected"
992                    .to_string(),
993            );
994        }
995        if self.gradient_stats.clipping_frequency < 0.1 && self.step_count > 0 {
996            recommendations.push(
997                "Consider decreasing the clipping threshold - low clipping frequency detected"
998                    .to_string(),
999            );
1000        }
1001        if let Ok(epsilon) = self.consumed_epsilon() {
1002            if epsilon / self.config.target_epsilon > 0.9 {
1003                recommendations.push(
1004                    "Privacy budget nearly exhausted - increase the noise multiplier or stop"
1005                        .to_string(),
1006                );
1007            }
1008        }
1009
1010        recommendations
1011    }
1012}
1013
1014// Implementation of helper structures
1015
1016impl AdaptiveClippingState {
1017    fn new(initial_threshold: f64, adaptationlr: f64, target_quantile: f64) -> Result<Self> {
1018        if !initial_threshold.is_finite() || initial_threshold <= 0.0 {
1019            return Err(OptimError::InvalidConfig(format!(
1020                "initial clipping threshold must be positive and finite, got {initial_threshold}"
1021            )));
1022        }
1023        if !adaptationlr.is_finite() || adaptationlr <= 0.0 {
1024            return Err(OptimError::InvalidConfig(format!(
1025                "adaptation rate must be positive and finite, got {adaptationlr}"
1026            )));
1027        }
1028        if !(0.0..=1.0).contains(&target_quantile) {
1029            return Err(OptimError::InvalidConfig(format!(
1030                "target quantile must be in [0, 1], got {target_quantile}"
1031            )));
1032        }
1033
1034        Ok(Self {
1035            current_threshold: initial_threshold,
1036            target_quantile,
1037            adaptationlr,
1038            fraction_history: VecDeque::with_capacity(1000),
1039            quantile_estimator: QuantileEstimator::new(target_quantile.clamp(0.01, 0.99)),
1040            updates: 0,
1041        })
1042    }
1043
1044    /// Record a (non-private) observed norm for diagnostics only.
1045    fn observe_norm(&mut self, norm: f64) {
1046        if norm.is_finite() {
1047            self.quantile_estimator.update(norm);
1048        }
1049    }
1050
1051    /// Geometric threshold update from a *privatized* below-threshold
1052    /// fraction (Andrew et al. 2021).
1053    fn update_threshold(&mut self, private_fraction_below: f64) {
1054        self.fraction_history.push_back(private_fraction_below);
1055        if self.fraction_history.len() > 1000 {
1056            self.fraction_history.pop_front();
1057        }
1058
1059        let factor = (-self.adaptationlr * (private_fraction_below - self.target_quantile)).exp();
1060        self.current_threshold = (self.current_threshold * factor).clamp(1e-6, 1e6);
1061        self.updates += 1;
1062    }
1063}
1064
1065impl QuantileEstimator {
1066    fn new(quantile: f64) -> Self {
1067        // The quantile itself lives in `p2_state`; a second copy here was never
1068        // read and could drift from the one the estimator actually uses.
1069        Self {
1070            p2_state: P2AlgorithmState::new(quantile),
1071            ema: 0.0,
1072            ema_decay: 0.99,
1073        }
1074    }
1075
1076    fn update(&mut self, value: f64) {
1077        self.p2_state.update(value);
1078
1079        if self.p2_state.count == 1 {
1080            self.ema = value;
1081        } else {
1082            self.ema = self.ema_decay * self.ema + (1.0 - self.ema_decay) * value;
1083        }
1084    }
1085
1086    /// Estimate of the configured quantile.
1087    fn estimate(&self) -> f64 {
1088        match self.p2_state.estimate() {
1089            Some(value) => value,
1090            None => self.ema,
1091        }
1092    }
1093}
1094
1095impl P2AlgorithmState {
1096    fn new(quantile: f64) -> Self {
1097        let p = quantile.clamp(0.0, 1.0);
1098        Self {
1099            heights: [0.0; 5],
1100            positions: [1.0, 2.0, 3.0, 4.0, 5.0],
1101            desired: [1.0, 1.0 + 2.0 * p, 1.0 + 4.0 * p, 3.0 + 2.0 * p, 5.0],
1102            increments: [0.0, p / 2.0, p, (1.0 + p) / 2.0, 1.0],
1103            count: 0,
1104            initial: Vec::with_capacity(5),
1105        }
1106    }
1107
1108    /// Full P-squared update (Jain & Chlamtac 1985): marker position
1109    /// increments followed by parabolic (falling back to linear) height
1110    /// adjustment. The earlier revision stopped updating after five samples
1111    /// and reported a frozen median.
1112    fn update(&mut self, value: f64) {
1113        if !value.is_finite() {
1114            return;
1115        }
1116
1117        self.count += 1;
1118
1119        if self.initial.len() < 5 {
1120            self.initial.push(value);
1121            if self.initial.len() == 5 {
1122                self.initial
1123                    .sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1124                for (i, &v) in self.initial.iter().enumerate() {
1125                    self.heights[i] = v;
1126                }
1127            }
1128            return;
1129        }
1130
1131        // 1. Find the cell k containing the new observation.
1132        let k = if value < self.heights[0] {
1133            self.heights[0] = value;
1134            0usize
1135        } else if value >= self.heights[4] {
1136            self.heights[4] = value;
1137            3usize
1138        } else {
1139            let mut cell = 0usize;
1140            for i in 0..4 {
1141                if self.heights[i] <= value && value < self.heights[i + 1] {
1142                    cell = i;
1143                    break;
1144                }
1145            }
1146            cell
1147        };
1148
1149        // 2. Increment positions above the cell and shift desired positions.
1150        for i in (k + 1)..5 {
1151            self.positions[i] += 1.0;
1152        }
1153        for i in 0..5 {
1154            self.desired[i] += self.increments[i];
1155        }
1156
1157        // 3. Adjust the interior markers.
1158        for i in 1..4 {
1159            let d = self.desired[i] - self.positions[i];
1160            let gap_up = self.positions[i + 1] - self.positions[i];
1161            let gap_down = self.positions[i - 1] - self.positions[i];
1162
1163            if (d >= 1.0 && gap_up > 1.0) || (d <= -1.0 && gap_down < -1.0) {
1164                let step = if d >= 0.0 { 1.0 } else { -1.0 };
1165
1166                let parabolic = self.parabolic_prediction(i, step);
1167                let new_height =
1168                    if self.heights[i - 1] < parabolic && parabolic < self.heights[i + 1] {
1169                        parabolic
1170                    } else {
1171                        self.linear_prediction(i, step)
1172                    };
1173
1174                self.heights[i] = new_height;
1175                self.positions[i] += step;
1176            }
1177        }
1178    }
1179
1180    fn parabolic_prediction(&self, i: usize, d: f64) -> f64 {
1181        let n_prev = self.positions[i - 1];
1182        let n_cur = self.positions[i];
1183        let n_next = self.positions[i + 1];
1184        let q_prev = self.heights[i - 1];
1185        let q_cur = self.heights[i];
1186        let q_next = self.heights[i + 1];
1187
1188        let denom = n_next - n_prev;
1189        if denom == 0.0 {
1190            return q_cur;
1191        }
1192
1193        let left =
1194            (n_cur - n_prev + d) * (q_next - q_cur) / (n_next - n_cur).max(f64::MIN_POSITIVE);
1195        let right =
1196            (n_next - n_cur - d) * (q_cur - q_prev) / (n_cur - n_prev).max(f64::MIN_POSITIVE);
1197
1198        q_cur + (d / denom) * (left + right)
1199    }
1200
1201    fn linear_prediction(&self, i: usize, d: f64) -> f64 {
1202        let neighbour = if d > 0.0 { i + 1 } else { i - 1 };
1203        let gap = self.positions[neighbour] - self.positions[i];
1204        if gap == 0.0 {
1205            return self.heights[i];
1206        }
1207        self.heights[i] + d * (self.heights[neighbour] - self.heights[i]) / gap
1208    }
1209
1210    /// Current estimate, or `None` before five observations are available.
1211    fn estimate(&self) -> Option<f64> {
1212        if self.initial.len() < 5 {
1213            None
1214        } else {
1215            Some(self.heights[2])
1216        }
1217    }
1218}
1219
1220impl PrivacyBudgetTracker {
1221    fn new(config: &DifferentialPrivacyConfig) -> Self {
1222        Self {
1223            epsilon_consumed: 0.0,
1224            target_epsilon: config.target_epsilon,
1225            reporting_delta: config.target_delta,
1226            consumption_history: Vec::new(),
1227        }
1228    }
1229
1230    fn record(
1231        &mut self,
1232        step: usize,
1233        epsilon_spent: f64,
1234        reporting_delta: f64,
1235        batchsize: usize,
1236        noise_multiplier: f64,
1237    ) {
1238        self.epsilon_consumed = epsilon_spent;
1239        self.consumption_history.push(PrivacyConsumption {
1240            step,
1241            epsilon_spent,
1242            reporting_delta,
1243            batchsize,
1244            noise_multiplier,
1245        });
1246
1247        if self.consumption_history.len() > 10_000 {
1248            self.consumption_history.remove(0);
1249        }
1250    }
1251}
1252
1253impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync> GradientStatistics<A> {
1254    fn new() -> Self {
1255        Self {
1256            norm_history: VecDeque::with_capacity(1000),
1257            clipping_frequency: 0.0,
1258            clipping_frequency_rate: 0.01,
1259            avg_norm: A::zero(),
1260            std_norm: A::zero(),
1261            percentiles: HashMap::new(),
1262            max_history_size: 1000,
1263        }
1264    }
1265
1266    fn update_norm(&mut self, norm: A) -> Result<()> {
1267        self.norm_history.push_back(norm);
1268        if self.norm_history.len() > self.max_history_size {
1269            self.norm_history.pop_front();
1270        }
1271
1272        let n = A::from(self.norm_history.len()).ok_or_else(|| {
1273            OptimError::InvalidConfig("failed to convert history length".to_string())
1274        })?;
1275        self.avg_norm = self.norm_history.iter().cloned().sum::<A>() / n;
1276
1277        let variance = self
1278            .norm_history
1279            .iter()
1280            .map(|&x| (x - self.avg_norm) * (x - self.avg_norm))
1281            .sum::<A>()
1282            / n;
1283        self.std_norm = variance.sqrt();
1284
1285        self.update_percentiles();
1286        Ok(())
1287    }
1288
1289    fn update_percentiles(&mut self) {
1290        if self.norm_history.is_empty() {
1291            return;
1292        }
1293
1294        let mut sorted: Vec<A> = self.norm_history.iter().cloned().collect();
1295        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1296
1297        for (label, q) in [("p50", 0.5), ("p90", 0.9), ("p99", 0.99)] {
1298            let idx = ((sorted.len() - 1) as f64 * q).round() as usize;
1299            if let Some(&value) = sorted.get(idx.min(sorted.len() - 1)) {
1300                self.percentiles.insert(label.to_string(), value);
1301            }
1302        }
1303    }
1304
1305    /// Exponential moving average of the clipping indicator.
1306    ///
1307    /// Both outcomes update the average; only ever moving it towards 1.0 (as
1308    /// an earlier revision did) drives the reported frequency to 1.0 no
1309    /// matter what happens.
1310    fn update_clipping(&mut self, was_clipped: bool) {
1311        let alpha = self.clipping_frequency_rate;
1312        let indicator = if was_clipped { 1.0 } else { 0.0 };
1313        self.clipping_frequency = (1.0 - alpha) * self.clipping_frequency + alpha * indicator;
1314    }
1315}
1316
1317impl<A: Float + Default + Clone + Send + Sync> NoiseCalibrator<A> {
1318    fn new(config: &DifferentialPrivacyConfig) -> Result<Self> {
1319        let noise_multiplier = A::from(config.noise_multiplier).ok_or_else(|| {
1320            OptimError::InvalidConfig("failed to convert noise multiplier".to_string())
1321        })?;
1322        let base_noise_scale =
1323            A::from(config.noise_multiplier * config.l2_norm_clip).ok_or_else(|| {
1324                OptimError::InvalidConfig("failed to convert base noise scale".to_string())
1325            })?;
1326
1327        Ok(Self {
1328            noise_multiplier,
1329            base_noise_scale,
1330            mechanism: config.noise_mechanism,
1331            calibration_history: Vec::new(),
1332        })
1333    }
1334
1335    fn update_calibration(
1336        &mut self,
1337        step: usize,
1338        gradientnorm: A,
1339        clipping_threshold: A,
1340        privacy_cost: A,
1341    ) {
1342        let noise_scale = self.noise_multiplier * clipping_threshold;
1343
1344        self.calibration_history.push(NoiseCalibration {
1345            step,
1346            noise_scale,
1347            gradientnorm,
1348            clipping_threshold,
1349            privacy_cost,
1350        });
1351
1352        if self.calibration_history.len() > 1000 {
1353            self.calibration_history.remove(0);
1354        }
1355    }
1356
1357    /// Mechanism this calibrator was built for.
1358    #[allow(dead_code)]
1359    fn mechanism(&self) -> NoiseMechanism {
1360        self.mechanism
1361    }
1362
1363    /// Noise scale implied by the configured clipping norm.
1364    #[allow(dead_code)]
1365    fn base_noise_scale(&self) -> A {
1366        self.base_noise_scale
1367    }
1368}
1369
1370/// Adaptive clipping statistics
1371#[derive(Debug, Clone)]
1372pub struct AdaptiveClippingStats {
1373    /// Threshold currently in force.
1374    pub current_threshold: f64,
1375    /// Target fraction of unclipped gradients.
1376    pub target_quantile: f64,
1377    /// EMA of the clipping indicator.
1378    pub clipping_frequency: f64,
1379    /// Mean observed gradient norm.
1380    pub avg_gradient_norm: f64,
1381    /// Std deviation of observed gradient norms.
1382    pub std_gradient_norm: f64,
1383    /// Geometric adaptation rate.
1384    pub adaptation_rate: f64,
1385    /// P-squared estimate of the target quantile of gradient norms.
1386    pub quantile_estimate: f64,
1387    /// Number of privatized threshold updates performed.
1388    pub threshold_updates: usize,
1389}
1390
1391/// Privacy accounting details
1392#[derive(Debug, Clone)]
1393pub struct PrivacyAccountingDetails {
1394    /// Append-only ledger segments.
1395    pub accounting_segments: Vec<AccountingSegment>,
1396    /// Per-step consumption records.
1397    pub privacy_consumption_history: Vec<PrivacyConsumption>,
1398    /// Gradient statistics snapshot.
1399    pub gradient_statistics: GradientStatsSnapshot,
1400    /// Noise calibration history.
1401    pub noise_calibration_history: Vec<NoiseCalibration<f64>>,
1402}
1403
1404/// Gradient statistics snapshot
1405#[derive(Debug, Clone)]
1406pub struct GradientStatsSnapshot {
1407    /// Mean gradient norm.
1408    pub avg_norm: f64,
1409    /// Std deviation of gradient norms.
1410    pub std_norm: f64,
1411    /// EMA of the clipping indicator.
1412    pub clipping_frequency: f64,
1413    /// Selected percentiles of observed gradient norms.
1414    pub percentiles: HashMap<String, f64>,
1415}
1416
1417/// Configuration validation result
1418#[derive(Debug, Clone)]
1419pub struct ConfigurationValidation {
1420    /// Whether the configuration is usable.
1421    pub is_valid: bool,
1422    /// Advisory warnings.
1423    pub warnings: Vec<String>,
1424    /// Blocking errors.
1425    pub errors: Vec<String>,
1426    /// Suggested adjustments.
1427    pub recommended_adjustments: Vec<String>,
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::*;
1433    use crate::optimizers::SGD;
1434    use crate::privacy::AccountingMethod;
1435    use scirs2_core::ndarray::{Array1, Ix1};
1436
1437    fn test_config() -> DifferentialPrivacyConfig {
1438        DifferentialPrivacyConfig {
1439            target_epsilon: 10.0,
1440            target_delta: 1e-5,
1441            noise_multiplier: 1.1,
1442            l2_norm_clip: 1.0,
1443            batch_size: 64,
1444            dataset_size: 50_000,
1445            max_steps: 1000,
1446            ..Default::default()
1447        }
1448    }
1449
1450    fn build(config: DifferentialPrivacyConfig) -> DPSGDOptimizer<SGD<f64>, f64, Ix1> {
1451        match DPSGDOptimizer::new(SGD::new(0.01), config) {
1452            Ok(optimizer) => optimizer,
1453            Err(err) => panic!("construction failed: {err}"),
1454        }
1455    }
1456
1457    #[test]
1458    fn test_dp_sgd_creation() {
1459        let dp_sgd = DPSGDOptimizer::<_, f64, Ix1>::new(
1460            SGD::new(0.01),
1461            DifferentialPrivacyConfig::default(),
1462        );
1463        assert!(dp_sgd.is_ok());
1464    }
1465
1466    #[test]
1467    fn test_per_example_step_runs_many_steps_with_monotone_epsilon() {
1468        let mut optimizer = build(test_config());
1469        let params = Array1::<f64>::zeros(4);
1470        let batch: Vec<Array1<f64>> = (0..8).map(|_| Array1::from_elem(4, 0.25)).collect();
1471
1472        let mut previous = 0.0;
1473        for step in 1..=100 {
1474            let result = optimizer.dp_step_per_example(&params, &batch);
1475            assert!(result.is_ok(), "step {step} failed: {result:?}");
1476            let epsilon = match optimizer.consumed_epsilon() {
1477                Ok(value) => value,
1478                Err(err) => panic!("accounting failed: {err}"),
1479            };
1480            assert!(
1481                epsilon > previous,
1482                "epsilon must increase strictly: {previous} -> {epsilon}"
1483            );
1484            previous = epsilon;
1485        }
1486
1487        let budget = match optimizer.get_privacy_budget() {
1488            Ok(budget) => budget,
1489            Err(err) => panic!("budget query failed: {err}"),
1490        };
1491        assert_eq!(budget.steps_taken, 100);
1492        assert!(budget.epsilon_consumed < budget.epsilon_consumed + budget.epsilon_remaining);
1493    }
1494
1495    #[test]
1496    fn test_budget_exhaustion_terminates_training() {
1497        let config = DifferentialPrivacyConfig {
1498            target_epsilon: 3.0,
1499            noise_multiplier: 1.0,
1500            batch_size: 64,
1501            dataset_size: 640,
1502            max_steps: 100_000,
1503            ..test_config()
1504        };
1505        let mut optimizer = build(config);
1506        let params = Array1::<f64>::zeros(2);
1507        let batch: Vec<Array1<f64>> = (0..64).map(|_| Array1::from_elem(2, 0.5)).collect();
1508
1509        let mut steps = 0;
1510        loop {
1511            match optimizer.dp_step_per_example(&params, &batch) {
1512                Ok(_) => {
1513                    steps += 1;
1514                    assert!(steps < 100_000, "budget was never enforced");
1515                }
1516                Err(OptimError::PrivacyBudgetExhausted { .. }) => break,
1517                Err(other) => panic!("unexpected error: {other}"),
1518            }
1519        }
1520
1521        assert!(steps > 0);
1522        let epsilon = match optimizer.consumed_epsilon() {
1523            Ok(value) => value,
1524            Err(err) => panic!("accounting failed: {err}"),
1525        };
1526        assert!(epsilon <= 3.0 + 1e-12);
1527    }
1528
1529    #[test]
1530    fn test_aggregate_step_is_opt_in() {
1531        let mut optimizer = build(test_config());
1532        let params = Array1::<f64>::zeros(2);
1533        let mut gradients = Array1::from_elem(2, 0.5);
1534        assert!(optimizer.dp_step(&params, &mut gradients, 8).is_err());
1535
1536        let mut acknowledged = build(DifferentialPrivacyConfig {
1537            acknowledge_aggregate_clipping: true,
1538            ..test_config()
1539        });
1540        assert!(acknowledged.dp_step(&params, &mut gradients, 8).is_ok());
1541    }
1542
1543    #[test]
1544    fn test_batch_size_is_used_by_accounting() {
1545        // A larger batch means a larger sampling probability and therefore a
1546        // larger privacy cost per step. An implementation that ignored the
1547        // batch size would report identical epsilons.
1548        let params = Array1::<f64>::zeros(2);
1549
1550        let mut small = build(test_config());
1551        let small_batch: Vec<Array1<f64>> = (0..4).map(|_| Array1::from_elem(2, 0.1)).collect();
1552        assert!(small.dp_step_per_example(&params, &small_batch).is_ok());
1553
1554        let mut large = build(test_config());
1555        let large_batch: Vec<Array1<f64>> = (0..512).map(|_| Array1::from_elem(2, 0.1)).collect();
1556        assert!(large.dp_step_per_example(&params, &large_batch).is_ok());
1557
1558        let eps_small = match small.consumed_epsilon() {
1559            Ok(value) => value,
1560            Err(err) => panic!("accounting failed: {err}"),
1561        };
1562        let eps_large = match large.consumed_epsilon() {
1563            Ok(value) => value,
1564            Err(err) => panic!("accounting failed: {err}"),
1565        };
1566        assert!(
1567            eps_large > eps_small,
1568            "batch size must affect accounting: {eps_small} vs {eps_large}"
1569        );
1570        assert_eq!(
1571            small.accounting_segments()[0].sampling_probability,
1572            4.0 / 50_000.0
1573        );
1574        assert_eq!(
1575            large.accounting_segments()[0].sampling_probability,
1576            512.0 / 50_000.0
1577        );
1578    }
1579
1580    #[test]
1581    fn test_changing_batch_size_appends_a_segment_and_never_rewrites_history() {
1582        let mut optimizer = build(test_config());
1583        let params = Array1::<f64>::zeros(2);
1584        let batch_a: Vec<Array1<f64>> = (0..8).map(|_| Array1::from_elem(2, 0.1)).collect();
1585        for _ in 0..5 {
1586            assert!(optimizer.dp_step_per_example(&params, &batch_a).is_ok());
1587        }
1588        let epsilon_before = match optimizer.consumed_epsilon() {
1589            Ok(value) => value,
1590            Err(err) => panic!("accounting failed: {err}"),
1591        };
1592
1593        assert!(optimizer.set_batch_size(256).is_ok());
1594        let batch_b: Vec<Array1<f64>> = (0..256).map(|_| Array1::from_elem(2, 0.1)).collect();
1595        assert!(optimizer.dp_step_per_example(&params, &batch_b).is_ok());
1596
1597        let segments = optimizer.accounting_segments();
1598        assert_eq!(segments.len(), 2, "a parameter change must open a segment");
1599        assert_eq!(segments[0].steps, 5);
1600        assert_eq!(segments[1].steps, 1);
1601
1602        let epsilon_after = match optimizer.consumed_epsilon() {
1603            Ok(value) => value,
1604            Err(err) => panic!("accounting failed: {err}"),
1605        };
1606        assert!(
1607            epsilon_after > epsilon_before,
1608            "already-spent privacy must never be reduced by a configuration change"
1609        );
1610    }
1611
1612    #[test]
1613    fn test_privacy_target_cannot_be_changed_mid_training() {
1614        let mut optimizer = build(test_config());
1615        let relaxed = DifferentialPrivacyConfig {
1616            target_epsilon: 100.0,
1617            ..test_config()
1618        };
1619        assert!(optimizer.update_privacy_config(relaxed).is_err());
1620
1621        let tightened = DifferentialPrivacyConfig {
1622            target_delta: 1e-9,
1623            ..test_config()
1624        };
1625        assert!(optimizer.update_privacy_config(tightened).is_err());
1626
1627        let operational = DifferentialPrivacyConfig {
1628            noise_multiplier: 2.0,
1629            ..test_config()
1630        };
1631        assert!(optimizer.update_privacy_config(operational).is_ok());
1632    }
1633
1634    #[test]
1635    fn test_unimplemented_mechanisms_and_methods_error() {
1636        for mechanism in [
1637            NoiseMechanism::TreeAggregation,
1638            NoiseMechanism::ImprovedComposition,
1639        ] {
1640            let mut optimizer = build(DifferentialPrivacyConfig {
1641                noise_mechanism: mechanism,
1642                ..test_config()
1643            });
1644            let params = Array1::<f64>::zeros(2);
1645            let batch = vec![Array1::from_elem(2, 0.1)];
1646            assert!(optimizer.dp_step_per_example(&params, &batch).is_err());
1647        }
1648
1649        for method in [
1650            AccountingMethod::AdvancedComposition,
1651            AccountingMethod::ZCDP,
1652        ] {
1653            let result = DPSGDOptimizer::<SGD<f64>, f64, Ix1>::new(
1654                SGD::new(0.01),
1655                DifferentialPrivacyConfig {
1656                    accounting_method: method,
1657                    ..test_config()
1658                },
1659            );
1660            assert!(result.is_err(), "{method:?} must not be silently accepted");
1661        }
1662    }
1663
1664    #[test]
1665    fn test_non_finite_gradients_are_rejected() {
1666        let mut optimizer = build(test_config());
1667        let params = Array1::<f64>::zeros(2);
1668        let batch = vec![Array1::from_vec(vec![f64::NAN, 1.0])];
1669        assert!(optimizer.dp_step_per_example(&params, &batch).is_err());
1670    }
1671
1672    #[test]
1673    fn test_adaptive_clipping_state() {
1674        let state = AdaptiveClippingState::new(1.0, 0.1, 0.5);
1675        assert!(state.is_ok());
1676        let state = match state {
1677            Ok(state) => state,
1678            Err(err) => panic!("construction failed: {err}"),
1679        };
1680        assert_eq!(state.current_threshold, 1.0);
1681        assert_eq!(state.adaptationlr, 0.1);
1682
1683        assert!(AdaptiveClippingState::new(0.0, 0.1, 0.5).is_err());
1684        assert!(AdaptiveClippingState::new(1.0, -1.0, 0.5).is_err());
1685        assert!(AdaptiveClippingState::new(1.0, 0.1, 2.0).is_err());
1686    }
1687
1688    #[test]
1689    fn test_adaptive_threshold_moves_towards_the_target_quantile() {
1690        let mut state = match AdaptiveClippingState::new(1.0, 0.5, 0.5) {
1691            Ok(state) => state,
1692            Err(err) => panic!("construction failed: {err}"),
1693        };
1694
1695        // Everything clipped (fraction below = 0 < target) -> raise C.
1696        state.update_threshold(0.0);
1697        assert!(state.current_threshold > 1.0);
1698
1699        // Nothing clipped (fraction below = 1 > target) -> lower C.
1700        let raised = state.current_threshold;
1701        state.update_threshold(1.0);
1702        assert!(state.current_threshold < raised);
1703        assert_eq!(state.updates, 2);
1704    }
1705
1706    #[test]
1707    fn test_privacy_budget_tracker() {
1708        let config = DifferentialPrivacyConfig::default();
1709        let tracker = PrivacyBudgetTracker::new(&config);
1710        assert_eq!(tracker.target_epsilon, config.target_epsilon);
1711        assert_eq!(tracker.epsilon_consumed, 0.0);
1712    }
1713
1714    #[test]
1715    fn test_p2_quantile_estimator_tracks_the_median() {
1716        let mut estimator = QuantileEstimator::new(0.5);
1717        for i in 1..=1000 {
1718            estimator.update(i as f64);
1719        }
1720        let estimate = estimator.estimate();
1721        assert!(
1722            (estimate - 500.0).abs() < 60.0,
1723            "P-squared median estimate {estimate} is far from 500"
1724        );
1725    }
1726
1727    #[test]
1728    fn test_p2_quantile_estimator_honours_the_requested_quantile() {
1729        let mut p90 = QuantileEstimator::new(0.9);
1730        let mut p10 = QuantileEstimator::new(0.1);
1731        for i in 1..=1000 {
1732            p90.update(i as f64);
1733            p10.update(i as f64);
1734        }
1735        let high = p90.estimate();
1736        let low = p10.estimate();
1737        assert!(
1738            high > low,
1739            "the 0.9 quantile ({high}) must exceed the 0.1 quantile ({low})"
1740        );
1741        assert!((high - 900.0).abs() < 120.0, "p90 estimate was {high}");
1742        assert!((low - 100.0).abs() < 120.0, "p10 estimate was {low}");
1743    }
1744
1745    #[test]
1746    fn test_p2_estimator_does_not_freeze_after_five_samples() {
1747        let mut estimator = QuantileEstimator::new(0.5);
1748        for i in 1..=5 {
1749            estimator.update(i as f64);
1750        }
1751        let early = estimator.estimate();
1752        for i in 6..=500 {
1753            estimator.update(i as f64);
1754        }
1755        let late = estimator.estimate();
1756        assert!(
1757            late > early,
1758            "the estimator must keep updating past five samples: {early} -> {late}"
1759        );
1760    }
1761
1762    #[test]
1763    fn test_p2_estimator_ignores_non_finite_input() {
1764        let mut estimator = QuantileEstimator::new(0.5);
1765        for i in 1..=10 {
1766            estimator.update(i as f64);
1767        }
1768        let before = estimator.estimate();
1769        estimator.update(f64::NAN);
1770        estimator.update(f64::INFINITY);
1771        assert_eq!(estimator.estimate(), before);
1772    }
1773
1774    #[test]
1775    fn test_gradient_statistics() {
1776        let mut stats = GradientStatistics::<f64>::new();
1777        assert!(stats.update_norm(1.0).is_ok());
1778        assert!(stats.update_norm(2.0).is_ok());
1779        assert!(stats.update_norm(3.0).is_ok());
1780
1781        assert_eq!(stats.avg_norm, 2.0);
1782        assert!(stats.std_norm > 0.0);
1783        assert!(stats.percentiles.contains_key("p50"));
1784    }
1785
1786    #[test]
1787    fn test_clipping_frequency_ema_moves_in_both_directions() {
1788        let mut stats = GradientStatistics::<f64>::new();
1789        for _ in 0..500 {
1790            stats.update_clipping(true);
1791        }
1792        let after_clipping = stats.clipping_frequency;
1793        assert!(after_clipping > 0.9);
1794
1795        for _ in 0..500 {
1796            stats.update_clipping(false);
1797        }
1798        assert!(
1799            stats.clipping_frequency < after_clipping,
1800            "the EMA must fall when clipping stops: {after_clipping} -> {}",
1801            stats.clipping_frequency
1802        );
1803    }
1804}