Skip to main content

optirs_core/privacy/private_hyperparameter_optimization/
optimizer.rs

1//! The private hyperparameter optimizer driver.
2//!
3//! Extracted from `types.rs` to keep every file under the 2000-line limit.
4
5use crate::error::{OptimError, Result};
6use crate::privacy::PrivacyBudget;
7use scirs2_core::numeric::Float;
8use std::collections::HashMap;
9use std::fmt::Debug;
10
11use super::budget_manager::{HPOBudgetManager, DEFAULT_SELECTION_BUDGET_FRACTION};
12use super::functions::{NoisyOptimizer, ObjectiveFn};
13use super::results::{PrivateResultsAggregator, SelectionReport, PRIVATE_TOP_K};
14use super::types::{
15    unix_timestamp, EvaluationStatus, HPOEvaluation, HyperparameterNoiseMechanism, NoiseParameters,
16    ObjectiveNoiseMechanism, OptimizationStats, ParameterSpace, PrivateBayesianOptimization,
17    PrivateHPOConfig, PrivateHPOResults, PrivateObjective, PrivateRandomSearch, SearchAlgorithm,
18};
19
20/// The registry key of the private optimizer that implements `algorithm`.
21///
22/// Returns [`OptimError::UnsupportedOperation`] for the five `SearchAlgorithm`
23/// variants that have no private implementation here. They used to be mapped to
24/// `"random_search"` by a `_ =>` arm, so the configured algorithm never ran and
25/// nothing said so.
26pub(crate) fn optimizer_key(algorithm: SearchAlgorithm) -> Result<&'static str> {
27    match algorithm {
28        SearchAlgorithm::RandomSearch => Ok("random_search"),
29        SearchAlgorithm::BayesianOptimization => Ok("bayesian_opt"),
30        other => Err(OptimError::UnsupportedOperation(format!(
31            "SearchAlgorithm::{other:?} has no differentially private implementation in this \
32             crate; configure SearchAlgorithm::RandomSearch or \
33             SearchAlgorithm::BayesianOptimization"
34        ))),
35    }
36}
37
38/// Privacy-preserving hyperparameter optimizer
39pub struct PrivateHyperparameterOptimizer<T: Float + Debug + Send + Sync + 'static> {
40    /// Configuration for privacy-preserving hyperparameter optimization
41    config: PrivateHPOConfig<T>,
42    /// Privacy budget manager
43    budget_manager: HPOBudgetManager,
44    /// Noisy optimization algorithms
45    noisy_optimizers: HashMap<String, Box<dyn NoisyOptimizer<T>>>,
46    /// Hyperparameter space definition
47    parameterspace: ParameterSpace<T>,
48    /// Objective function with privacy guarantees
49    private_objective: PrivateObjective<T>,
50    /// Results aggregator with privacy
51    results_aggregator: PrivateResultsAggregator<T>,
52}
53
54impl<T: Float + Debug + Send + Sync + 'static> PrivateHyperparameterOptimizer<T> {
55    /// Create new private hyperparameter optimizer.
56    ///
57    /// The objective's global sensitivity **must** be declared in
58    /// `config.sensitivity_bounds` (under `"objective"`, or as the only entry).
59    /// Every evaluation releases its objective under a differentially private
60    /// noise mechanism whose scale is `sensitivity / epsilon`, so an undeclared
61    /// sensitivity has no safe default: substituting `1.0` silently rescales the
62    /// noise, and every epsilon reported afterwards would describe a guarantee
63    /// the run did not deliver. Construction therefore fails instead of guessing,
64    /// whether or not `private_model_selection` is set.
65    pub fn new(config: PrivateHPOConfig<T>, parameterspace: ParameterSpace<T>) -> Result<Self> {
66        if parameterspace.parameters.is_empty() {
67            return Err(OptimError::InvalidConfig(
68                "the parameter space declares no hyperparameter to search".to_string(),
69            ));
70        }
71
72        // The objective release always needs a sensitivity; the private selection
73        // needs the same number to calibrate the exponential mechanism.
74        let objective_sensitivity = match config.sensitivity_bounds.objective_sensitivity() {
75            Some(sensitivity) => {
76                let as_f64 = sensitivity.to_f64().unwrap_or(f64::NAN);
77                if !as_f64.is_finite() || as_f64 <= 0.0 {
78                    return Err(OptimError::InvalidPrivacyConfig(format!(
79                        "the declared objective sensitivity is {as_f64}; it must be positive \
80                         and finite"
81                    )));
82                }
83                sensitivity
84            }
85            None => {
86                return Err(OptimError::InvalidPrivacyConfig(format!(
87                    "no objective sensitivity is declared in \
88                     sensitivity_bounds.global_sensitivity (expected the key `{}`); the \
89                     objective's noise scale is sensitivity / epsilon and the exponential \
90                     mechanism is calibrated with the same number, so neither can be derived \
91                     without it",
92                    super::selection::OBJECTIVE_SENSITIVITY_KEY
93                )))
94            }
95        };
96        let selection_sensitivity = if config.private_model_selection {
97            Some(objective_sensitivity)
98        } else {
99            None
100        };
101
102        let selection_fraction = if config.private_model_selection {
103            DEFAULT_SELECTION_BUDGET_FRACTION
104        } else {
105            0.0
106        };
107        let budget_manager = HPOBudgetManager::with_selection_reserve(
108            config.base_privacyconfig.clone(),
109            config.budget_allocation,
110            config.num_evaluations,
111            selection_fraction,
112        )?;
113        // Only two search algorithms have a private implementation. The other
114        // five used to fall through to `PrivateRandomSearch`, so a caller asking
115        // for TPE (or a genetic search, or simulated annealing) silently got
116        // uniform random proposals and no indication that the algorithm it
117        // configured had never run.
118        let optimizer_name = optimizer_key(config.search_algorithm)?;
119        let mut noisy_optimizers: HashMap<String, Box<dyn NoisyOptimizer<T>>> = HashMap::new();
120        match config.search_algorithm {
121            SearchAlgorithm::RandomSearch => {
122                noisy_optimizers.insert(
123                    optimizer_name.to_string(),
124                    Box::new(PrivateRandomSearch::new(config.clone())?),
125                );
126            }
127            SearchAlgorithm::BayesianOptimization => {
128                noisy_optimizers.insert(
129                    optimizer_name.to_string(),
130                    Box::new(PrivateBayesianOptimization::new(config.clone())?),
131                );
132            }
133            other => {
134                return Err(OptimError::UnsupportedOperation(format!(
135                    "SearchAlgorithm::{other:?} has no differentially private implementation in \
136                     this crate; configure SearchAlgorithm::RandomSearch or \
137                     SearchAlgorithm::BayesianOptimization"
138                )))
139            }
140        }
141
142        // The Gaussian selection mechanism is an (epsilon, delta) mechanism. Its
143        // delta comes from the base configuration's reporting delta -- never from
144        // a hardcoded constant -- and the classic Gaussian bound additionally
145        // requires each draw's epsilon to be at most 1, which is checked here so
146        // the failure surfaces at construction rather than mid-run.
147        let selection_delta = if matches!(
148            config.noise_mechanism,
149            HyperparameterNoiseMechanism::Gaussian
150        ) {
151            let delta = config.base_privacyconfig.target_delta;
152            if !delta.is_finite() || !(0.0..1.0).contains(&delta) || delta <= 0.0 {
153                return Err(OptimError::InvalidPrivacyConfig(format!(
154                    "Gaussian hyperparameter selection needs a reporting delta in (0, 1), but \
155                     base_privacyconfig.target_delta is {delta}"
156                )));
157            }
158            // `aggregate_results` draws `k = PRIVATE_TOP_K.min(evaluations)` times
159            // and splits half the selection epsilon across them, so the per-draw
160            // epsilon is largest when the run produces the fewest evaluations.
161            // Using `num_evaluations` here matches the k the run will actually
162            // use; a run cut short by budget exhaustion draws fewer times, and
163            // `gaussian_sigma` refuses the oversized epsilon at that point rather
164            // than quietly widening the guarantee.
165            let draws = PRIVATE_TOP_K.min(config.num_evaluations.max(1));
166            let per_draw_epsilon = budget_manager.selection_epsilon() * 0.5 / draws as f64;
167            if per_draw_epsilon > 1.0 {
168                return Err(OptimError::InvalidPrivacyConfig(format!(
169                    "Gaussian selection would draw at epsilon {per_draw_epsilon} per selection, \
170                     but the classic Gaussian bound requires epsilon <= 1; lower target_epsilon \
171                     or choose HyperparameterNoiseMechanism::Exponential"
172                )));
173            }
174            Some(delta)
175        } else {
176            None
177        };
178
179        let results_aggregator = match selection_sensitivity {
180            Some(sensitivity) => PrivateResultsAggregator::with_selection_budget(
181                budget_manager.selection_epsilon(),
182                selection_delta,
183                config.noise_mechanism,
184                sensitivity,
185                1.0,
186            )?,
187            None => PrivateResultsAggregator::new()?,
188        };
189
190        // The objective release is charged out of each evaluation's grant, so
191        // it must use a scalar-perturbation mechanism. `noise_mechanism`
192        // configures the *selection*; the objective always uses Laplace unless
193        // the user asked for Gaussian.
194        let objective_mechanism = match config.noise_mechanism {
195            HyperparameterNoiseMechanism::Gaussian => HyperparameterNoiseMechanism::Gaussian,
196            _ => HyperparameterNoiseMechanism::Laplace,
197        };
198        let private_objective =
199            PrivateObjective::with_noise_mechanism(ObjectiveNoiseMechanism::with_parameters(
200                objective_mechanism,
201                NoiseParameters {
202                    scale: T::one(),
203                    sensitivity: objective_sensitivity,
204                    epsilon: 1.0,
205                    delta: Some(
206                        config
207                            .base_privacyconfig
208                            .target_delta
209                            .max(f64::MIN_POSITIVE),
210                    ),
211                },
212            )?)?;
213
214        Ok(Self {
215            config,
216            budget_manager,
217            noisy_optimizers,
218            parameterspace,
219            private_objective,
220            results_aggregator,
221        })
222    }
223
224    /// Seed every stochastic component deterministically (tests only).
225    ///
226    /// The sub-seeds are domain-separated. Seeding the objective's noise
227    /// mechanism and the selection mechanism from the *same* seed makes both
228    /// draw the same underlying uniform stream, so the evaluation that receives
229    /// the largest objective noise also receives the largest selection noise --
230    /// the private selection then reproduces the exact argmax and looks
231    /// deterministic when it is not. That correlation is an artefact of the test
232    /// harness, not of the mechanisms, and this is where it is avoided.
233    pub fn seed_for_tests(&mut self, seed: u64) {
234        const OBJECTIVE_DOMAIN: u64 = 0x9E37_79B9_7F4A_7C15;
235        const SELECTION_DOMAIN: u64 = 0xC2B2_AE3D_27D4_EB4F;
236        self.results_aggregator
237            .seed_for_tests(seed.wrapping_mul(SELECTION_DOMAIN) | 1);
238        self.private_objective
239            .seed_for_tests(seed.wrapping_mul(OBJECTIVE_DOMAIN) | 1);
240    }
241
242    /// The epsilon spent so far across every objective release and the private
243    /// selection.
244    ///
245    /// This used to be `privacy_accountant() -> &MomentsAccountant`. That
246    /// accountant was constructed from `base_privacyconfig`'s DP-SGD parameters
247    /// (`noise_multiplier`, `batch_size`, `dataset_size`) and then **never
248    /// stepped**, so it reported the spend of a training run that had not
249    /// happened while the hyperparameter search's real, pure-epsilon spend was
250    /// tracked entirely by [`HPOBudgetManager`]. A moments accountant models
251    /// subsampled-Gaussian composition and is the wrong primitive for the
252    /// Laplace / exponential releases this optimizer performs, so it is gone
253    /// rather than fed fabricated `(sigma, q)` pairs. Read the real ledger here
254    /// or in [`PrivateHPOResults::total_privacy_cost`].
255    pub fn total_privacy_cost(&self) -> PrivacyBudget {
256        self.budget_manager.get_total_consumed_budget()
257    }
258
259    /// The budget manager.
260    pub fn budget_manager(&self) -> &HPOBudgetManager {
261        &self.budget_manager
262    }
263
264    /// The private objective, including the noise mechanism and the scale it
265    /// last used.
266    pub fn private_objective(&self) -> &PrivateObjective<T> {
267        &self.private_objective
268    }
269
270    /// Optimize hyperparameters with differential privacy.
271    ///
272    /// The final configuration is chosen by the configured private selection
273    /// mechanism when `private_model_selection` is set. When it is not, the
274    /// exact argmax is returned and [`PrivateHPOResults::selection`] records
275    /// `was_private: false` so the caller cannot mistake it for a private
276    /// choice.
277    pub fn optimize(&mut self, objective_fn: ObjectiveFn<T>) -> Result<PrivateHPOResults<T>> {
278        self.private_objective.set_objective(objective_fn)?;
279        let started = std::time::Instant::now();
280        let mut evaluations = Vec::new();
281        let mut evaluation_durations: Vec<f64> = Vec::new();
282        let mut failed_evaluations = 0usize;
283        let mut last_error: Option<OptimError> = None;
284        let mut best_score_so_far = T::neg_infinity();
285        let mut convergence_iteration = None;
286        // `new` already refused every algorithm without an implementation, so
287        // this cannot silently pick a different optimizer than the caller asked
288        // for; it propagates rather than defaulting all the same.
289        let optimizer_name = optimizer_key(self.config.search_algorithm)?;
290        for iteration in 0..self.config.num_evaluations {
291            if !self.budget_manager.has_budget_remaining()? {
292                break;
293            }
294            let evaluation_budget = self.budget_manager.get_evaluation_budget(iteration)?;
295            let config = if let Some(optimizer) = self.noisy_optimizers.get_mut(optimizer_name) {
296                optimizer.suggest_next(&self.parameterspace, &evaluations, &evaluation_budget)?
297            } else {
298                return Err(OptimError::InvalidConfig(
299                    "No optimizer available".to_string(),
300                ));
301            };
302            let evaluation_started = std::time::Instant::now();
303            let result = match self.private_objective.evaluate(&config, &evaluation_budget) {
304                Ok(result) => result,
305                Err(err) => {
306                    // A failing objective still touched the data, so charge its
307                    // grant (fail closed) and record the failure rather than
308                    // pretending the trial never happened. If every trial fails
309                    // the error is propagated below.
310                    failed_evaluations += 1;
311                    last_error = Some(err);
312                    self.budget_manager
313                        .record_evaluation(&evaluation_budget, 0.0)?;
314                    continue;
315                }
316            };
317            evaluation_durations.push(evaluation_started.elapsed().as_secs_f64());
318            let evaluation = HPOEvaluation {
319                id: format!("eval_{}", iteration),
320                configuration: config.clone(),
321                result: result.clone(),
322                privacy_cost: evaluation_budget.clone(),
323                timestamp: unix_timestamp()?,
324                metadata: HashMap::new(),
325            };
326            if result.objective_value > best_score_so_far {
327                best_score_so_far = result.objective_value;
328                convergence_iteration = Some(iteration);
329            }
330            if let Some(optimizer) = self.noisy_optimizers.get_mut(optimizer_name) {
331                optimizer.update(&config, &result, &evaluation_budget)?;
332            }
333            evaluations.push(evaluation);
334            self.budget_manager.record_evaluation(
335                &evaluation_budget,
336                result.objective_value.to_f64().unwrap_or(0.0),
337            )?;
338            if self.should_stop_early(&evaluations)? {
339                break;
340            }
341        }
342
343        if evaluations.is_empty() {
344            return Err(last_error.unwrap_or(OptimError::PrivacyBudgetExhausted {
345                consumed_epsilon: self.budget_manager.epsilon_spent(),
346                target_epsilon: self.config.base_privacyconfig.target_epsilon,
347            }));
348        }
349
350        let final_results = self.results_aggregator.aggregate_results(&evaluations)?;
351
352        let (bestconfiguration, best_score, selection) = if self.config.private_model_selection {
353            let spent = self
354                .results_aggregator
355                .selection_mechanism()
356                .epsilon_spent();
357            self.budget_manager.record_selection_spend(spent)?;
358            let mut report = self.results_aggregator.selection_report();
359            let chosen = final_results
360                .topconfigurations
361                .first()
362                .cloned()
363                .ok_or_else(|| {
364                    OptimError::InvalidState(
365                        "the private selection returned no configuration".to_string(),
366                    )
367                })?;
368            report.selected_probability = final_results
369                .model_selection
370                .as_ref()
371                .map(|selection| selection.selection_confidence);
372            (Some(chosen.0), chosen.1, report)
373        } else {
374            // Non-private fallback: the exact argmax. Recorded as such.
375            let mut best_index = 0usize;
376            let mut best = T::neg_infinity();
377            for (index, evaluation) in evaluations.iter().enumerate() {
378                if evaluation.result.objective_value > best {
379                    best = evaluation.result.objective_value;
380                    best_index = index;
381                }
382            }
383            (
384                Some(evaluations[best_index].configuration.clone()),
385                best,
386                SelectionReport {
387                    was_private: false,
388                    mechanism: "exact_argmax".to_string(),
389                    epsilon_spent: 0.0,
390                    delta_spent: 0.0,
391                    utility_sensitivity: f64::NAN,
392                    selected_probability: None,
393                },
394            )
395        };
396
397        let optimization_stats = self.compute_optimization_stats(
398            &evaluations,
399            &evaluation_durations,
400            failed_evaluations,
401            started.elapsed().as_secs_f64(),
402            convergence_iteration,
403        )?;
404
405        Ok(PrivateHPOResults {
406            bestconfiguration,
407            best_score,
408            all_evaluations: evaluations,
409            final_results,
410            total_privacy_cost: self.budget_manager.get_total_consumed_budget(),
411            optimization_stats,
412            selection,
413        })
414    }
415    /// Check early stopping criteria
416    fn should_stop_early(&self, evaluations: &[HPOEvaluation<T>]) -> Result<bool> {
417        if !self.config.early_stopping.enabled {
418            return Ok(false);
419        }
420        if evaluations.len() < self.config.early_stopping.patience {
421            return Ok(false);
422        }
423        let recent_scores: Vec<T> = evaluations
424            .iter()
425            .rev()
426            .take(self.config.early_stopping.patience)
427            .map(|eval| eval.result.objective_value)
428            .collect();
429        let best_recent =
430            recent_scores
431                .iter()
432                .fold(T::neg_infinity(), |acc, &x| if x > acc { x } else { acc });
433        let best_overall = evaluations
434            .iter()
435            .map(|eval| eval.result.objective_value)
436            .fold(T::neg_infinity(), |acc, x| if x > acc { x } else { acc });
437        let improvement = best_recent - best_overall;
438        Ok(improvement
439            < T::from(self.config.early_stopping.min_improvement).unwrap_or_else(|| T::zero()))
440    }
441    /// Compute optimization statistics from the run that just finished.
442    ///
443    /// The previous implementation returned all zeros regardless of what the
444    /// run did, so every reported statistic was fabricated.
445    fn compute_optimization_stats(
446        &self,
447        evaluations: &[HPOEvaluation<T>],
448        durations: &[f64],
449        failed_evaluations: usize,
450        total_time: f64,
451        convergence_iteration: Option<usize>,
452    ) -> Result<OptimizationStats<T>> {
453        let successful = evaluations
454            .iter()
455            .filter(|evaluation| matches!(evaluation.result.status, EvaluationStatus::Success))
456            .count();
457        let average_evaluation_time = if durations.is_empty() {
458            0.0
459        } else {
460            durations.iter().sum::<f64>() / durations.len() as f64
461        };
462
463        // Budget efficiency: score improvement achieved per unit epsilon spent.
464        let epsilon_spent = self.budget_manager.epsilon_spent();
465        let budget_efficiency = if epsilon_spent > 0.0 && evaluations.len() >= 2 {
466            let first = evaluations[0]
467                .result
468                .objective_value
469                .to_f64()
470                .unwrap_or(0.0);
471            let best = evaluations
472                .iter()
473                .filter_map(|evaluation| evaluation.result.objective_value.to_f64())
474                .fold(f64::NEG_INFINITY, f64::max);
475            if best.is_finite() {
476                (best - first) / epsilon_spent
477            } else {
478                0.0
479            }
480        } else {
481            0.0
482        };
483
484        Ok(OptimizationStats {
485            total_evaluations: evaluations.len() + failed_evaluations,
486            successful_evaluations: successful,
487            failed_evaluations,
488            average_evaluation_time,
489            total_optimization_time: total_time,
490            convergence_iteration,
491            budget_efficiency,
492            _phantom: std::marker::PhantomData,
493        })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::super::types::ParameterConfiguration;
500    use super::*;
501    use crate::privacy::private_hyperparameter_optimization::selection::OBJECTIVE_SENSITIVITY_KEY;
502    use crate::privacy::private_hyperparameter_optimization::types::{
503        BudgetAllocationStrategy, EarlyStoppingConfig, ParameterBounds, ParameterDefinition,
504        ParameterType, ParameterValue, SensitivityBounds, ValidationStrategy,
505    };
506    use crate::privacy::DifferentialPrivacyConfig;
507
508    fn sensitivity_bounds(declared: Option<f64>) -> SensitivityBounds<f64> {
509        let mut global_sensitivity = HashMap::new();
510        if let Some(value) = declared {
511            global_sensitivity.insert(OBJECTIVE_SENSITIVITY_KEY.to_string(), value);
512        }
513        SensitivityBounds {
514            global_sensitivity,
515            local_sensitivity: HashMap::new(),
516            smooth_sensitivity: HashMap::new(),
517        }
518    }
519
520    fn config(private_selection: bool, declared: Option<f64>) -> PrivateHPOConfig<f64> {
521        PrivateHPOConfig {
522            base_privacyconfig: DifferentialPrivacyConfig {
523                target_epsilon: 4.0,
524                ..DifferentialPrivacyConfig::default()
525            },
526            budget_allocation: BudgetAllocationStrategy::Equal,
527            search_algorithm: SearchAlgorithm::RandomSearch,
528            num_evaluations: 10,
529            cv_folds: 3,
530            early_stopping: EarlyStoppingConfig {
531                enabled: false,
532                patience: 3,
533                min_improvement: 1e-4,
534                max_evaluations: 10,
535            },
536            noise_mechanism: HyperparameterNoiseMechanism::Laplace,
537            sensitivity_bounds: sensitivity_bounds(declared),
538            private_model_selection: private_selection,
539            validation_strategy: ValidationStrategy::HoldOut,
540        }
541    }
542
543    fn space() -> ParameterSpace<f64> {
544        let mut parameters = HashMap::new();
545        parameters.insert(
546            "learning_rate".to_string(),
547            ParameterDefinition {
548                name: "learning_rate".to_string(),
549                param_type: ParameterType::Continuous,
550                bounds: ParameterBounds {
551                    min: Some(0.0),
552                    max: Some(1.0),
553                    step: None,
554                    valid_values: None,
555                },
556                prior: None,
557                transformation: None,
558            },
559        );
560        ParameterSpace {
561            parameters,
562            constraints: Vec::new(),
563            defaultconfig: None,
564        }
565    }
566
567    /// A deterministic objective peaking at learning_rate = 0.75.
568    fn objective() -> ObjectiveFn<f64> {
569        Box::new(
570            |config: &ParameterConfiguration<f64>| match config.values.get("learning_rate") {
571                Some(ParameterValue::Continuous(rate)) => Ok(1.0 - (rate - 0.75).abs()),
572                other => Err(crate::error::OptimError::InvalidParameter(format!(
573                    "expected a continuous learning_rate, got {other:?}"
574                ))),
575            },
576        )
577    }
578
579    fn learning_rate(config: &ParameterConfiguration<f64>) -> f64 {
580        match config.values.get("learning_rate") {
581            Some(ParameterValue::Continuous(rate)) => *rate,
582            other => panic!("expected a continuous learning_rate, got {other:?}"),
583        }
584    }
585
586    #[test]
587    fn private_selection_requires_a_declared_objective_sensitivity() {
588        // Guessing the sensitivity would silently invalidate the epsilon the
589        // exponential mechanism is calibrated with.
590        let outcome = PrivateHyperparameterOptimizer::new(config(true, None), space());
591        let message = match outcome {
592            Err(err) => err.to_string(),
593            Ok(_) => panic!("an undeclared sensitivity must be refused"),
594        };
595        assert!(
596            message.contains(OBJECTIVE_SENSITIVITY_KEY),
597            "got: {message}"
598        );
599        assert!(
600            PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()).is_ok(),
601            "a declared sensitivity must be accepted"
602        );
603        for bad in [0.0f64, -1.0, f64::NAN] {
604            assert!(
605                PrivateHyperparameterOptimizer::new(config(true, Some(bad)), space()).is_err(),
606                "sensitivity {bad} must be refused"
607            );
608        }
609    }
610
611    #[test]
612    fn an_empty_parameter_space_is_refused() {
613        let empty = ParameterSpace {
614            parameters: HashMap::new(),
615            constraints: Vec::new(),
616            defaultconfig: None,
617        };
618        // A declared sensitivity keeps this isolated to the empty-space check:
619        // with `None` it would now also fail for the missing sensitivity.
620        assert!(PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), empty).is_err());
621    }
622
623    #[test]
624    fn an_end_to_end_run_selects_privately_and_charges_for_it() {
625        let mut optimizer =
626            match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
627                Ok(optimizer) => optimizer,
628                Err(err) => panic!("construction failed: {err}"),
629            };
630        optimizer.seed_for_tests(11);
631        let results = match optimizer.optimize(objective()) {
632            Ok(results) => results,
633            Err(err) => panic!("optimize failed: {err}"),
634        };
635
636        assert_eq!(results.all_evaluations.len(), 10);
637        assert!(results.bestconfiguration.is_some());
638        assert!(
639            results.selection.was_private,
640            "the selection must be reported as private"
641        );
642        // The configured `noise_mechanism` drives the selection, so a Laplace
643        // configuration selects by Laplace report-noisy-max.
644        assert_eq!(results.selection.mechanism, "laplace_report_noisy_max");
645        assert!(
646            results.selection.epsilon_spent > 0.0,
647            "the selection must cost epsilon, got {}",
648            results.selection.epsilon_spent
649        );
650        assert_eq!(results.selection.utility_sensitivity, 1.0);
651        match results.selection.selected_probability {
652            Some(probability) => {
653                assert!(
654                    (0.0..=1.0).contains(&probability),
655                    "probability {probability} is not a probability"
656                );
657            }
658            None => panic!("the mechanism's own selection probability must be reported"),
659        }
660
661        // The whole budget is accounted for and never exceeded.
662        let spent = results.total_privacy_cost.epsilon_consumed;
663        assert!(
664            spent > 0.0 && spent <= 4.0 + 1e-9,
665            "spent {spent} of a 4.0 budget"
666        );
667        assert!(results.total_privacy_cost.epsilon_remaining >= 0.0);
668        assert_eq!(results.total_privacy_cost.delta_consumed, 0.0);
669
670        // The statistics are measured, not the fabricated zeros they used to be.
671        assert_eq!(results.optimization_stats.total_evaluations, 10);
672        assert_eq!(results.optimization_stats.successful_evaluations, 10);
673        assert_eq!(results.optimization_stats.failed_evaluations, 0);
674        assert!(results.optimization_stats.total_optimization_time > 0.0);
675        assert!(results.optimization_stats.convergence_iteration.is_some());
676
677        // The summary statistics are noisy, not a copy of the mean.
678        assert!(results.final_results.summary_stats.noisy_std > 0.0);
679        assert!(results.final_results.confidence_intervals.is_some());
680        assert!(!results.final_results.topconfigurations.is_empty());
681        assert!(results.final_results.model_selection.is_some());
682    }
683
684    #[test]
685    fn the_reported_configuration_is_not_always_the_exact_argmax() {
686        // The core regression: an exact argmax over utilities computed from the
687        // private data leaks the selection. Across independent runs of the same
688        // deterministic objective the reported configuration must sometimes
689        // differ from the best evaluated one.
690        let mut deviations = 0usize;
691        for seed in 0..24u64 {
692            let mut optimizer =
693                match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
694                    Ok(optimizer) => optimizer,
695                    Err(err) => panic!("construction failed: {err}"),
696                };
697            optimizer.seed_for_tests(seed);
698            let results = match optimizer.optimize(objective()) {
699                Ok(results) => results,
700                Err(err) => panic!("optimize failed: {err}"),
701            };
702            let best_observed = results
703                .all_evaluations
704                .iter()
705                .map(|evaluation| evaluation.result.objective_value)
706                .fold(f64::NEG_INFINITY, f64::max);
707            if (results.best_score - best_observed).abs() > 1e-12 {
708                deviations += 1;
709            }
710        }
711        // At epsilon/draw = 0.04 with Delta_u = 1 the mechanism is close to
712        // uniform over the 10 candidates, so the argmax should be returned about
713        // 1 run in 10; requiring at least half the runs to deviate fails with
714        // probability well under 1e-6 under that model, and fails *always* if the
715        // selection is the exact argmax.
716        assert!(
717            deviations >= 12,
718            "only {deviations}/24 runs deviated from the exact argmax; the selection is not \
719             behaving like a private mechanism"
720        );
721    }
722
723    #[test]
724    fn a_non_private_selection_is_reported_as_such() {
725        let mut optimizer =
726            match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
727                Ok(optimizer) => optimizer,
728                Err(err) => panic!("construction failed: {err}"),
729            };
730        optimizer.seed_for_tests(5);
731        let results = match optimizer.optimize(objective()) {
732            Ok(results) => results,
733            Err(err) => panic!("optimize failed: {err}"),
734        };
735        assert!(
736            !results.selection.was_private,
737            "an exact argmax must not be reported as private"
738        );
739        assert_eq!(results.selection.mechanism, "exact_argmax");
740        assert_eq!(results.selection.epsilon_spent, 0.0);
741
742        // With no private selection the argmax is exactly what is returned.
743        let best_observed = results
744            .all_evaluations
745            .iter()
746            .map(|evaluation| evaluation.result.objective_value)
747            .fold(f64::NEG_INFINITY, f64::max);
748        assert!((results.best_score - best_observed).abs() < 1e-12);
749    }
750
751    #[test]
752    fn the_released_objectives_are_noisy_not_the_raw_values() {
753        // `add_noise` used to fall through to `Ok(value)` for every mechanism
754        // except Gaussian, and even then used a constant scale.
755        let mut optimizer =
756            match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
757                Ok(optimizer) => optimizer,
758                Err(err) => panic!("construction failed: {err}"),
759            };
760        optimizer.seed_for_tests(3);
761        let results = match optimizer.optimize(objective()) {
762            Ok(results) => results,
763            Err(err) => panic!("optimize failed: {err}"),
764        };
765        let mut noisy_count = 0usize;
766        for evaluation in &results.all_evaluations {
767            let rate = learning_rate(&evaluation.configuration);
768            let exact = 1.0 - (rate - 0.75).abs();
769            if (evaluation.result.objective_value - exact).abs() > 1e-9 {
770                noisy_count += 1;
771            }
772            assert!(
773                evaluation.result.standard_error.is_some(),
774                "the release must report the noise scale that was applied"
775            );
776        }
777        assert_eq!(
778            noisy_count,
779            results.all_evaluations.len(),
780            "every released objective must be perturbed"
781        );
782    }
783
784    #[test]
785    fn a_failing_objective_is_counted_and_propagated_when_nothing_succeeds() {
786        let mut optimizer =
787            match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
788                Ok(optimizer) => optimizer,
789                Err(err) => panic!("construction failed: {err}"),
790            };
791        let always_fails: ObjectiveFn<f64> = Box::new(|_| {
792            Err(crate::error::OptimError::ComputationError(
793                "the trial crashed".to_string(),
794            ))
795        });
796        let message = match optimizer.optimize(always_fails) {
797            Err(err) => err.to_string(),
798            Ok(_) => panic!("a run in which every trial failed must not succeed"),
799        };
800        assert!(message.contains("the trial crashed"), "got: {message}");
801    }
802
803    #[test]
804    fn an_unset_objective_errors_instead_of_scoring_zero() {
805        // `PrivateObjective::new` used to default to `|_| Ok(0.0)`.
806        let mut objective: PrivateObjective<f64> = match PrivateObjective::new() {
807            Ok(objective) => objective,
808            Err(err) => panic!("construction failed: {err}"),
809        };
810        let config = ParameterConfiguration {
811            values: HashMap::new(),
812            id: "c".to_string(),
813            metadata: HashMap::new(),
814        };
815        let budget = crate::privacy::PrivacyBudget {
816            epsilon_consumed: 0.5,
817            ..crate::privacy::PrivacyBudget::default()
818        };
819        let message = match objective.evaluate(&config, &budget) {
820            Err(err) => err.to_string(),
821            Ok(result) => panic!("an unset objective scored {:?}", result.objective_value),
822        };
823        assert!(message.contains("no objective function"), "got: {message}");
824    }
825
826    #[test]
827    fn a_zero_epsilon_grant_is_refused_by_the_objective_release() {
828        let mut objective: PrivateObjective<f64> = match PrivateObjective::new() {
829            Ok(objective) => objective,
830            Err(err) => panic!("construction failed: {err}"),
831        };
832        let ok = objective.set_objective(Box::new(|_| Ok(1.0)));
833        assert!(ok.is_ok());
834        let config = ParameterConfiguration {
835            values: HashMap::new(),
836            id: "c".to_string(),
837            metadata: HashMap::new(),
838        };
839        for epsilon in [0.0f64, -1.0, f64::NAN] {
840            let budget = crate::privacy::PrivacyBudget {
841                epsilon_consumed: epsilon,
842                ..crate::privacy::PrivacyBudget::default()
843            };
844            assert!(
845                objective.evaluate(&config, &budget).is_err(),
846                "an epsilon of {epsilon} must not buy a release"
847            );
848        }
849    }
850
851    #[test]
852    fn the_configured_noise_mechanism_drives_the_selection() {
853        for (mechanism, expected) in [
854            (
855                HyperparameterNoiseMechanism::Exponential,
856                "exponential_mechanism",
857            ),
858            (
859                HyperparameterNoiseMechanism::NoisyMax,
860                "gumbel_report_noisy_max",
861            ),
862            (
863                HyperparameterNoiseMechanism::Laplace,
864                "laplace_report_noisy_max",
865            ),
866            (
867                HyperparameterNoiseMechanism::Gaussian,
868                "gaussian_report_noisy_max",
869            ),
870        ] {
871            let mut hpo_config = config(true, Some(1.0));
872            hpo_config.noise_mechanism = mechanism;
873            let mut optimizer = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
874                Ok(optimizer) => optimizer,
875                Err(err) => panic!("{mechanism:?} construction failed: {err}"),
876            };
877            optimizer.seed_for_tests(23);
878            let results = match optimizer.optimize(objective()) {
879                Ok(results) => results,
880                Err(err) => panic!("{mechanism:?} optimize failed: {err}"),
881            };
882            assert_eq!(results.selection.mechanism, expected);
883            assert!(results.selection.was_private);
884            assert!(results.selection.epsilon_spent > 0.0);
885        }
886    }
887
888    #[test]
889    fn the_bayesian_search_path_also_runs_end_to_end() {
890        let mut hpo_config = config(true, Some(1.0));
891        hpo_config.search_algorithm = SearchAlgorithm::BayesianOptimization;
892        let mut optimizer = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
893            Ok(optimizer) => optimizer,
894            Err(err) => panic!("construction failed: {err}"),
895        };
896        optimizer.seed_for_tests(19);
897        let results = match optimizer.optimize(objective()) {
898            Ok(results) => results,
899            Err(err) => panic!("optimize failed: {err}"),
900        };
901        assert_eq!(results.all_evaluations.len(), 10);
902        for evaluation in &results.all_evaluations {
903            assert_eq!(
904                evaluation.configuration.values.len(),
905                1,
906                "every Bayesian proposal must set the parameter"
907            );
908        }
909        assert!(results.selection.was_private);
910    }
911
912    #[test]
913    fn the_objective_release_also_requires_a_declared_sensitivity() {
914        // Regression: the objective sensitivity used to be read with
915        // `.unwrap_or_else(T::one)`, so an undeclared sensitivity silently became
916        // 1.0 whenever `private_model_selection` was off. The objective's noise
917        // scale is `sensitivity / epsilon`, so a true sensitivity of 8 would have
918        // been noised eight times too weakly while the run still reported the
919        // configured epsilon. The old code constructed happily here.
920        let outcome = PrivateHyperparameterOptimizer::new(config(false, None), space());
921        let message = match outcome {
922            Err(err) => err.to_string(),
923            Ok(_) => panic!("an undeclared objective sensitivity must be refused"),
924        };
925        assert!(
926            message.contains(OBJECTIVE_SENSITIVITY_KEY),
927            "the error must name the key the sensitivity is expected under, got: {message}"
928        );
929        assert!(
930            PrivateHyperparameterOptimizer::new(config(false, Some(2.0)), space()).is_ok(),
931            "a declared sensitivity must be accepted with private selection off"
932        );
933    }
934
935    #[test]
936    fn the_declared_sensitivity_reaches_the_objective_noise_scale() {
937        // The scale recorded after a release must be the one the mechanism
938        // actually used: `sensitivity / epsilon` for Laplace. Two runs differing
939        // only in the declared sensitivity must record scales in that ratio, so a
940        // constant substituted for the declaration cannot pass.
941        let mut scales = Vec::new();
942        for declared in [1.0f64, 4.0] {
943            let mut optimizer =
944                match PrivateHyperparameterOptimizer::new(config(false, Some(declared)), space()) {
945                    Ok(optimizer) => optimizer,
946                    Err(err) => panic!("construction failed for sensitivity {declared}: {err}"),
947                };
948            optimizer.seed_for_tests(7);
949            if let Err(err) = optimizer.optimize(objective()) {
950                panic!("optimize failed for sensitivity {declared}: {err}");
951            }
952            let params = optimizer
953                .private_objective()
954                .noise_mechanism()
955                .noise_params();
956            assert_eq!(params.sensitivity, declared);
957            let epsilon = params.epsilon;
958            assert!(epsilon > 0.0, "the release must have been charged epsilon");
959            let expected = declared / epsilon;
960            assert!(
961                (params.scale - expected).abs() < 1e-12,
962                "recorded scale {} is not sensitivity/epsilon = {expected}",
963                params.scale
964            );
965            scales.push(params.scale);
966        }
967        assert!(
968            (scales[1] / scales[0] - 4.0).abs() < 1e-9,
969            "quadrupling the declared sensitivity must quadruple the noise scale, got {scales:?}"
970        );
971    }
972
973    #[test]
974    fn the_reported_privacy_cost_is_the_real_ledger_not_an_unstepped_accountant() {
975        // Regression: `privacy_accountant()` handed out a `MomentsAccountant`
976        // built from the DP-SGD parameters in `base_privacyconfig` and never
977        // stepped, so it reported zero spend after a full search while the real
978        // spend sat in `HPOBudgetManager`. The accessor now reads that ledger.
979        let mut optimizer =
980            match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
981                Ok(optimizer) => optimizer,
982                Err(err) => panic!("construction failed: {err}"),
983            };
984        assert_eq!(
985            optimizer.total_privacy_cost().epsilon_consumed,
986            0.0,
987            "nothing has been released yet"
988        );
989        optimizer.seed_for_tests(31);
990        let results = match optimizer.optimize(objective()) {
991            Ok(results) => results,
992            Err(err) => panic!("optimize failed: {err}"),
993        };
994
995        let reported = optimizer.total_privacy_cost();
996        assert!(
997            reported.epsilon_consumed > 0.0,
998            "a completed search must report a positive spend, got {}",
999            reported.epsilon_consumed
1000        );
1001        assert!(
1002            (reported.epsilon_consumed - results.total_privacy_cost.epsilon_consumed).abs() < 1e-12,
1003            "the accessor and the results must read the same ledger: {} vs {}",
1004            reported.epsilon_consumed,
1005            results.total_privacy_cost.epsilon_consumed
1006        );
1007        assert!(
1008            reported.epsilon_consumed <= 4.0 + 1e-9,
1009            "the spend must not exceed the 4.0 target, got {}",
1010            reported.epsilon_consumed
1011        );
1012        // The private selection is part of that spend, so the ledger must cover
1013        // at least what the selection itself reports charging.
1014        assert!(
1015            reported.epsilon_consumed >= results.selection.epsilon_spent,
1016            "the ledger {} does not cover the selection's own charge {}",
1017            reported.epsilon_consumed,
1018            results.selection.epsilon_spent
1019        );
1020    }
1021
1022    #[test]
1023    fn search_algorithms_without_a_private_implementation_are_refused() {
1024        // Regression: a `_ =>` arm mapped GridSearch, GeneticAlgorithm,
1025        // ParticleSwarm, SimulatedAnnealing and TPE onto `PrivateRandomSearch`,
1026        // so the configured algorithm never ran and the caller was never told.
1027        for algorithm in [
1028            SearchAlgorithm::GridSearch,
1029            SearchAlgorithm::GeneticAlgorithm,
1030            SearchAlgorithm::ParticleSwarm,
1031            SearchAlgorithm::SimulatedAnnealing,
1032            SearchAlgorithm::TPE,
1033        ] {
1034            let mut hpo_config = config(true, Some(1.0));
1035            hpo_config.search_algorithm = algorithm;
1036            let message = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
1037                Err(err) => err.to_string(),
1038                Ok(_) => panic!(
1039                    "{algorithm:?} has no private implementation and must not be substituted"
1040                ),
1041            };
1042            assert!(
1043                message.contains(&format!("{algorithm:?}")),
1044                "the error must name the refused algorithm, got: {message}"
1045            );
1046            assert!(
1047                optimizer_key(algorithm).is_err(),
1048                "{algorithm:?} must not resolve to an optimizer key"
1049            );
1050        }
1051
1052        assert_eq!(
1053            optimizer_key(SearchAlgorithm::RandomSearch).ok(),
1054            Some("random_search")
1055        );
1056        assert_eq!(
1057            optimizer_key(SearchAlgorithm::BayesianOptimization).ok(),
1058            Some("bayesian_opt")
1059        );
1060    }
1061}