1use crate::error::{OptimError, Result};
37use crate::privacy::noise_mechanisms::ExponentialMechanism as ValueExponentialMechanism;
38use scirs2_core::numeric::Float;
39use std::fmt::Debug;
40
41use super::types::{
42 os_seeded_hpo_rng, HpoRng, HyperparameterNoiseMechanism, SelectionMechanism,
43 SelectionParameters, SensitivityBounds, SummaryStatistics, UtilityFunction,
44 UtilityFunctionType,
45};
46
47pub const OBJECTIVE_SENSITIVITY_KEY: &str = "objective";
50
51#[derive(Debug, Clone)]
53pub struct SelectionOutcome {
54 pub index: usize,
56 pub epsilon_spent: f64,
58 pub delta_spent: f64,
60 pub mechanism: &'static str,
62}
63
64fn open_unit_sample(rng: &mut HpoRng) -> f64 {
69 let raw: f64 = rng.gen_range(0.0..1.0);
70 if raw <= 0.0 {
71 f64::MIN_POSITIVE
72 } else if raw >= 1.0 {
73 1.0 - f64::EPSILON
74 } else {
75 raw
76 }
77}
78
79pub fn laplace_sample(rng: &mut HpoRng, scale: f64) -> Result<f64> {
81 if !scale.is_finite() || scale <= 0.0 {
82 return Err(OptimError::InvalidParameter(format!(
83 "the Laplace scale must be positive and finite, got {scale}"
84 )));
85 }
86 let uniform = open_unit_sample(rng) - 0.5;
87 let magnitude = (1.0 - 2.0 * uniform.abs()).max(f64::MIN_POSITIVE);
88 Ok(-scale * uniform.signum() * magnitude.ln())
89}
90
91pub fn gumbel_sample(rng: &mut HpoRng) -> f64 {
93 let uniform = open_unit_sample(rng);
94 -(-uniform.ln()).ln()
95}
96
97pub fn gaussian_sample(rng: &mut HpoRng, sigma: f64) -> Result<f64> {
99 if !sigma.is_finite() || sigma <= 0.0 {
100 return Err(OptimError::InvalidParameter(format!(
101 "the Gaussian scale must be positive and finite, got {sigma}"
102 )));
103 }
104 let first = open_unit_sample(rng);
105 let second = open_unit_sample(rng);
106 Ok(sigma * (-2.0 * first.ln()).sqrt() * (std::f64::consts::TAU * second).cos())
107}
108
109pub fn gaussian_sigma(sensitivity: f64, epsilon: f64, delta: f64) -> Result<f64> {
114 if !sensitivity.is_finite() || sensitivity <= 0.0 {
115 return Err(OptimError::InvalidParameter(format!(
116 "the sensitivity must be positive and finite, got {sensitivity}"
117 )));
118 }
119 if !epsilon.is_finite() || epsilon <= 0.0 {
120 return Err(OptimError::InvalidParameter(format!(
121 "epsilon must be positive and finite, got {epsilon}"
122 )));
123 }
124 if epsilon > 1.0 {
125 return Err(OptimError::InvalidParameter(format!(
126 "the classic Gaussian-mechanism bound requires epsilon <= 1, got {epsilon}; use a \
127 pure-epsilon selection mechanism instead"
128 )));
129 }
130 if !delta.is_finite() || !(0.0..1.0).contains(&delta) || delta <= 0.0 {
131 return Err(OptimError::InvalidParameter(format!(
132 "the Gaussian mechanism requires a delta in (0, 1), got {delta}"
133 )));
134 }
135 Ok((2.0 * (1.25 / delta).ln()).sqrt() * sensitivity / epsilon)
136}
137
138fn utilities_as_f64<T: Float + Debug + Send + Sync + 'static>(utilities: &[T]) -> Result<Vec<f64>> {
140 if utilities.is_empty() {
141 return Err(OptimError::InvalidParameter(
142 "a private selection needs at least one candidate".to_string(),
143 ));
144 }
145 utilities
146 .iter()
147 .enumerate()
148 .map(|(index, value)| {
149 let as_f64 = value.to_f64().ok_or_else(|| {
150 OptimError::InvalidParameter(format!(
151 "utility {index} cannot be represented as f64"
152 ))
153 })?;
154 if !as_f64.is_finite() {
155 return Err(OptimError::InvalidParameter(format!(
156 "utility {index} is {as_f64}; a private selection cannot be made over \
157 non-finite utilities"
158 )));
159 }
160 Ok(as_f64)
161 })
162 .collect()
163}
164
165fn exact_argmax(utilities: &[f64]) -> Result<usize> {
167 let mut best = 0usize;
168 let mut best_value = f64::NEG_INFINITY;
169 for (index, value) in utilities.iter().enumerate() {
170 if *value > best_value {
171 best_value = *value;
172 best = index;
173 }
174 }
175 if best_value.is_finite() {
176 Ok(best)
177 } else {
178 Err(OptimError::InvalidParameter(
179 "no finite utility was supplied".to_string(),
180 ))
181 }
182}
183
184pub fn exponential_mechanism_index(
191 utilities: &[f64],
192 sensitivity: f64,
193 epsilon: f64,
194 seed: Option<u64>,
195) -> Result<usize> {
196 if utilities.is_empty() {
197 return Err(OptimError::InvalidParameter(
198 "a private selection needs at least one candidate".to_string(),
199 ));
200 }
201 let table = utilities.to_vec();
202 let quality = Box::new(move |candidate: &f64| {
203 let index = *candidate as usize;
204 table.get(index).copied().unwrap_or(f64::NEG_INFINITY)
205 });
206 let mut mechanism = match seed {
207 Some(seed) => ValueExponentialMechanism::<f64>::new_with_seed(quality, seed),
208 None => ValueExponentialMechanism::<f64>::new(quality),
209 };
210 let candidates: Vec<f64> = (0..utilities.len()).map(|index| index as f64).collect();
211 let chosen = mechanism.select_output(&candidates, sensitivity, epsilon)?;
212 let index = chosen as usize;
213 if index >= utilities.len() {
214 return Err(OptimError::InvalidState(format!(
215 "the exponential mechanism returned index {index} for {} candidates",
216 utilities.len()
217 )));
218 }
219 Ok(index)
220}
221
222pub fn exponential_mechanism_probabilities(
229 utilities: &[f64],
230 sensitivity: f64,
231 epsilon: f64,
232) -> Result<Vec<f64>> {
233 if utilities.is_empty() {
234 return Err(OptimError::InvalidParameter(
235 "selection probabilities need at least one candidate".to_string(),
236 ));
237 }
238 if !sensitivity.is_finite() || sensitivity <= 0.0 {
239 return Err(OptimError::InvalidParameter(format!(
240 "the utility sensitivity must be positive and finite, got {sensitivity}"
241 )));
242 }
243 if !epsilon.is_finite() || epsilon <= 0.0 {
244 return Err(OptimError::InvalidParameter(format!(
245 "epsilon must be positive and finite, got {epsilon}"
246 )));
247 }
248 let max = utilities.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
249 if !max.is_finite() {
250 return Err(OptimError::InvalidParameter(
251 "no finite utility was supplied".to_string(),
252 ));
253 }
254 let weights: Vec<f64> = utilities
255 .iter()
256 .map(|utility| (epsilon * (utility - max) / (2.0 * sensitivity)).exp())
257 .collect();
258 let total: f64 = weights.iter().sum();
259 if !total.is_finite() || total <= 0.0 {
260 return Err(OptimError::InvalidState(format!(
261 "the selection weights sum to {total}"
262 )));
263 }
264 Ok(weights.into_iter().map(|weight| weight / total).collect())
265}
266
267pub fn summary_mean_noise_scale(count: usize, value_range: f64, epsilon: f64) -> Result<f64> {
272 if count == 0 {
273 return Err(OptimError::InvalidParameter(
274 "the observation count must be positive".to_string(),
275 ));
276 }
277 if !value_range.is_finite() || value_range <= 0.0 {
278 return Err(OptimError::InvalidParameter(format!(
279 "the public value range must be positive and finite, got {value_range}"
280 )));
281 }
282 if !epsilon.is_finite() || epsilon <= 0.0 {
283 return Err(OptimError::InvalidParameter(format!(
284 "epsilon must be positive and finite, got {epsilon}"
285 )));
286 }
287 Ok(value_range / (count as f64 * (epsilon / 3.0)))
288}
289
290pub fn report_noisy_max_gumbel(
296 utilities: &[f64],
297 sensitivity: f64,
298 epsilon: f64,
299 rng: &mut HpoRng,
300) -> Result<usize> {
301 if !sensitivity.is_finite() || sensitivity <= 0.0 {
302 return Err(OptimError::InvalidParameter(format!(
303 "report-noisy-max requires a positive finite sensitivity, got {sensitivity}"
304 )));
305 }
306 if !epsilon.is_finite() || epsilon <= 0.0 {
307 return Err(OptimError::InvalidParameter(format!(
308 "report-noisy-max requires a positive finite epsilon, got {epsilon}"
309 )));
310 }
311 let scale = 2.0 * sensitivity / epsilon;
312 let noisy: Vec<f64> = utilities
313 .iter()
314 .map(|utility| utility + scale * gumbel_sample(rng))
315 .collect();
316 exact_argmax(&noisy)
317}
318
319pub fn report_noisy_max_laplace(
321 utilities: &[f64],
322 sensitivity: f64,
323 epsilon: f64,
324 rng: &mut HpoRng,
325) -> Result<usize> {
326 if !sensitivity.is_finite() || sensitivity <= 0.0 {
327 return Err(OptimError::InvalidParameter(format!(
328 "report-noisy-max requires a positive finite sensitivity, got {sensitivity}"
329 )));
330 }
331 if !epsilon.is_finite() || epsilon <= 0.0 {
332 return Err(OptimError::InvalidParameter(format!(
333 "report-noisy-max requires a positive finite epsilon, got {epsilon}"
334 )));
335 }
336 let scale = 2.0 * sensitivity / epsilon;
337 let mut noisy = Vec::with_capacity(utilities.len());
338 for utility in utilities {
339 noisy.push(utility + laplace_sample(rng, scale)?);
340 }
341 exact_argmax(&noisy)
342}
343
344pub fn report_noisy_max_gaussian(
346 utilities: &[f64],
347 sensitivity: f64,
348 epsilon: f64,
349 delta: f64,
350 rng: &mut HpoRng,
351) -> Result<usize> {
352 let sigma = gaussian_sigma(2.0 * sensitivity, epsilon, delta)?;
353 let mut noisy = Vec::with_capacity(utilities.len());
354 for utility in utilities {
355 noisy.push(utility + gaussian_sample(rng, sigma)?);
356 }
357 exact_argmax(&noisy)
358}
359
360impl<T: Float + Debug + Send + Sync + 'static> UtilityFunction<T> {
361 pub fn evaluate(&self, objective: T) -> Result<T> {
367 let value = objective.to_f64().ok_or_else(|| {
368 OptimError::InvalidParameter(
369 "the objective value cannot be represented as f64".to_string(),
370 )
371 })?;
372 if !value.is_finite() {
373 return Err(OptimError::InvalidParameter(format!(
374 "the objective value {value} is not finite"
375 )));
376 }
377 let scale = self
378 .parameters()
379 .first()
380 .and_then(|parameter| parameter.to_f64())
381 .unwrap_or(1.0);
382 let utility = match self.function_type() {
383 UtilityFunctionType::Linear => scale * value,
384 UtilityFunctionType::Quadratic => scale * value * value,
385 UtilityFunctionType::Exponential => {
386 let exponent = scale * value;
387 if exponent > 700.0 {
388 return Err(OptimError::InvalidParameter(format!(
389 "the exponential utility overflows for objective {value} at scale {scale}"
390 )));
391 }
392 exponent.exp()
393 }
394 UtilityFunctionType::Logarithmic => {
395 if value <= 0.0 {
396 return Err(OptimError::InvalidParameter(format!(
397 "the logarithmic utility needs a positive objective, got {value}"
398 )));
399 }
400 scale * value.ln()
401 }
402 UtilityFunctionType::Custom => {
403 return Err(OptimError::UnsupportedOperation(
404 "UtilityFunctionType::Custom carries no function to evaluate; register a \
405 concrete utility instead"
406 .to_string(),
407 ))
408 }
409 };
410 T::from(utility).ok_or_else(|| {
411 OptimError::InvalidParameter(format!(
412 "the computed utility {utility} cannot be represented in the parameter type"
413 ))
414 })
415 }
416
417 pub fn evaluate_multi(&self, objectives: &[T]) -> Result<T> {
419 let weights = self.multi_objective_weights().ok_or_else(|| {
420 OptimError::InvalidState(
421 "no multi-objective weights are configured on this utility function".to_string(),
422 )
423 })?;
424 if weights.len() != objectives.len() {
425 return Err(OptimError::DimensionMismatch(format!(
426 "{} weights were configured for {} objectives",
427 weights.len(),
428 objectives.len()
429 )));
430 }
431 let mut total = 0.0f64;
432 for (weight, objective) in weights.iter().zip(objectives.iter()) {
433 let weight = weight.to_f64().unwrap_or(f64::NAN);
434 let scalar = self.evaluate(*objective)?.to_f64().unwrap_or(f64::NAN);
435 total += weight * scalar;
436 }
437 if !total.is_finite() {
438 return Err(OptimError::InvalidParameter(
439 "the scalarised utility is not finite".to_string(),
440 ));
441 }
442 T::from(total).ok_or_else(|| {
443 OptimError::InvalidParameter(format!(
444 "the scalarised utility {total} cannot be represented in the parameter type"
445 ))
446 })
447 }
448}
449
450impl<T: Float + Debug + Send + Sync + 'static> SensitivityBounds<T> {
451 pub fn objective_sensitivity(&self) -> Option<T> {
458 if let Some(declared) = self.global_sensitivity.get(OBJECTIVE_SENSITIVITY_KEY) {
459 return Some(*declared);
460 }
461 self.global_sensitivity
462 .values()
463 .filter(|value| value.is_finite() && **value > T::zero())
464 .fold(None, |accumulated: Option<T>, value| match accumulated {
465 Some(current) if current >= *value => Some(current),
466 _ => Some(*value),
467 })
468 }
469
470 pub fn smooth_beta(&self, parameter: &str) -> Option<T> {
472 self.smooth_sensitivity
473 .get(parameter)
474 .map(|params| params.beta)
475 }
476
477 pub fn local_bounds(&self, parameter: &str) -> Option<(T, T)> {
479 self.local_sensitivity.get(parameter).copied()
480 }
481}
482
483impl<T: Float + Debug + Send + Sync + 'static> SelectionMechanism<T> {
484 pub fn with_parameters(
486 mechanism_type: HyperparameterNoiseMechanism,
487 selection_params: SelectionParameters<T>,
488 utility_function: UtilityFunction<T>,
489 ) -> Result<Self> {
490 let mut mechanism = Self::new();
491 mechanism.set_mechanism_type(mechanism_type);
492 mechanism.set_utility_function(utility_function);
493 mechanism.set_selection_parameters(selection_params)?;
494 Ok(mechanism)
495 }
496
497 pub fn seed_for_tests(&mut self, seed: u64) {
499 self.set_rng(scirs2_core::random::Random::seed(seed));
500 self.set_test_seed(Some(seed));
501 }
502
503 pub fn reseed_from_os(&mut self) {
505 self.set_rng(os_seeded_hpo_rng());
506 self.set_test_seed(None);
507 }
508
509 pub fn select_index(&mut self, utilities: &[T]) -> Result<SelectionOutcome> {
514 let table = utilities_as_f64(utilities)?;
515 let epsilon = self.selection_params().epsilon;
516 let sensitivity = self
517 .selection_params()
518 .utility_sensitivity
519 .to_f64()
520 .ok_or_else(|| {
521 OptimError::InvalidParameter(
522 "the utility sensitivity cannot be represented as f64".to_string(),
523 )
524 })?;
525 if !epsilon.is_finite() || epsilon <= 0.0 {
526 return Err(OptimError::InvalidParameter(format!(
527 "private selection requires a positive finite epsilon, got {epsilon}"
528 )));
529 }
530 if !sensitivity.is_finite() || sensitivity <= 0.0 {
531 return Err(OptimError::InvalidParameter(format!(
532 "private selection requires a positive finite utility sensitivity, got \
533 {sensitivity}"
534 )));
535 }
536
537 let (table, index_map) = match self.selection_params().threshold {
541 Some(threshold) => {
542 let threshold = threshold.to_f64().ok_or_else(|| {
543 OptimError::InvalidParameter(
544 "the selection threshold cannot be represented as f64".to_string(),
545 )
546 })?;
547 let mut kept = Vec::new();
548 let mut map = Vec::new();
549 for (index, value) in table.iter().enumerate() {
550 if *value >= threshold {
551 kept.push(*value);
552 map.push(index);
553 }
554 }
555 if kept.is_empty() {
556 return Err(OptimError::InvalidState(format!(
557 "no candidate reaches the configured selection threshold {threshold}"
558 )));
559 }
560 (kept, Some(map))
561 }
562 None => (table, None),
563 };
564
565 let mechanism_type = self.mechanism_type();
566 let seed = self.test_seed();
567 let local_index = match mechanism_type {
568 HyperparameterNoiseMechanism::Exponential => {
569 let call_seed = seed.map(|seed| seed.wrapping_add(self.selection_count() as u64));
570 exponential_mechanism_index(&table, sensitivity, epsilon, call_seed)?
571 }
572 HyperparameterNoiseMechanism::NoisyMax => {
573 report_noisy_max_gumbel(&table, sensitivity, epsilon, self.rng_mut())?
574 }
575 HyperparameterNoiseMechanism::Laplace => {
576 report_noisy_max_laplace(&table, sensitivity, epsilon, self.rng_mut())?
577 }
578 HyperparameterNoiseMechanism::Gaussian => {
579 let delta = self.selection_params().delta.ok_or_else(|| {
580 OptimError::InvalidConfig(
581 "Gaussian selection is an (epsilon, delta) mechanism but no delta is \
582 configured in SelectionParameters"
583 .to_string(),
584 )
585 })?;
586 report_noisy_max_gaussian(&table, sensitivity, epsilon, delta, self.rng_mut())?
587 }
588 HyperparameterNoiseMechanism::SparseVector => {
589 return Err(OptimError::UnsupportedOperation(
590 "the sparse vector technique answers a stream of threshold queries and is not \
591 a one-shot selection mechanism; use \
592 privacy::noise_mechanisms::SparseVectorMechanism, or select with \
593 HyperparameterNoiseMechanism::Exponential"
594 .to_string(),
595 ))
596 }
597 };
598
599 let index = match index_map {
600 Some(map) => map[local_index],
601 None => local_index,
602 };
603 let delta_spent = match mechanism_type {
604 HyperparameterNoiseMechanism::Gaussian => self.selection_params().delta.unwrap_or(0.0),
605 _ => 0.0,
606 };
607 self.record_selection(epsilon, delta_spent);
608 Ok(SelectionOutcome {
609 index,
610 epsilon_spent: epsilon,
611 delta_spent,
612 mechanism: mechanism_name(mechanism_type),
613 })
614 }
615}
616
617pub fn mechanism_name(mechanism: HyperparameterNoiseMechanism) -> &'static str {
619 match mechanism {
620 HyperparameterNoiseMechanism::Exponential => "exponential_mechanism",
621 HyperparameterNoiseMechanism::Gaussian => "gaussian_report_noisy_max",
622 HyperparameterNoiseMechanism::Laplace => "laplace_report_noisy_max",
623 HyperparameterNoiseMechanism::NoisyMax => "gumbel_report_noisy_max",
624 HyperparameterNoiseMechanism::SparseVector => "sparse_vector",
625 }
626}
627
628pub const SUMMARY_QUANTILES: [f64; 3] = [0.25, 0.5, 0.75];
634
635#[derive(Debug, Clone)]
640pub struct NoisySummary<T: Float + Debug + Send + Sync + 'static> {
641 pub statistics: SummaryStatistics<T>,
643 pub epsilon_spent: f64,
645 pub mean_noise_scale: f64,
648}
649
650pub fn noisy_summary_statistics<T: Float + Debug + Send + Sync + 'static>(
675 values: &[T],
676 value_range: f64,
677 epsilon: f64,
678 rng: &mut HpoRng,
679) -> Result<NoisySummary<T>> {
680 if values.is_empty() {
681 return Err(OptimError::InvalidParameter(
682 "summary statistics need at least one observation".to_string(),
683 ));
684 }
685 if !value_range.is_finite() || value_range <= 0.0 {
686 return Err(OptimError::InvalidParameter(format!(
687 "the public value range must be positive and finite, got {value_range}"
688 )));
689 }
690 if !epsilon.is_finite() || epsilon <= 0.0 {
691 return Err(OptimError::InvalidParameter(format!(
692 "noisy summary statistics need a positive finite epsilon, got {epsilon}"
693 )));
694 }
695
696 let observations = utilities_as_f64(values)?;
697 let count = observations.len() as f64;
698 let per_statistic_epsilon = epsilon / 3.0;
699 let mut epsilon_spent = 0.0f64;
700
701 let mean = observations.iter().sum::<f64>() / count;
703 let mean_noise_scale = value_range / (count * per_statistic_epsilon);
704 let noisy_mean = mean + laplace_sample(rng, mean_noise_scale)?;
705 epsilon_spent += per_statistic_epsilon;
706
707 let variance = observations
709 .iter()
710 .map(|value| (value - mean) * (value - mean))
711 .sum::<f64>()
712 / count;
713 let std_scale = 2.0 * value_range / (count.sqrt() * per_statistic_epsilon);
714 let noisy_std = (variance.sqrt() + laplace_sample(rng, std_scale)?).max(0.0);
715 epsilon_spent += per_statistic_epsilon;
716
717 let mut sorted = observations.clone();
721 sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
722 let per_quantile_epsilon = per_statistic_epsilon / SUMMARY_QUANTILES.len() as f64;
723 let mut noisy_quantiles = Vec::with_capacity(SUMMARY_QUANTILES.len());
724 let mut noisy_median = None;
725 for quantile in SUMMARY_QUANTILES {
726 let index = private_quantile_index(&sorted, quantile, per_quantile_epsilon, rng)?;
727 epsilon_spent += per_quantile_epsilon;
728 if (quantile - 0.5).abs() < f64::EPSILON {
729 noisy_median = Some(sorted[index]);
730 }
731 let value = T::from(sorted[index]).ok_or_else(|| {
732 OptimError::InvalidParameter("a quantile cannot be represented".to_string())
733 })?;
734 noisy_quantiles.push((quantile, value));
735 }
736 let noisy_median = noisy_median.ok_or_else(|| {
737 OptimError::InvalidState(
738 "SUMMARY_QUANTILES must contain 0.5 so the median comes out of the quantile releases"
739 .to_string(),
740 )
741 })?;
742
743 let statistics = SummaryStatistics {
744 noisy_mean: T::from(noisy_mean).ok_or_else(|| {
745 OptimError::InvalidParameter("the noisy mean cannot be represented".to_string())
746 })?,
747 noisy_std: T::from(noisy_std).ok_or_else(|| {
748 OptimError::InvalidParameter(
749 "the noisy standard deviation cannot be represented".to_string(),
750 )
751 })?,
752 noisy_median: T::from(noisy_median).ok_or_else(|| {
753 OptimError::InvalidParameter("the noisy median cannot be represented".to_string())
754 })?,
755 noisy_quantiles,
756 };
757
758 Ok(NoisySummary {
759 statistics,
760 epsilon_spent,
761 mean_noise_scale,
762 })
763}
764
765fn private_quantile_index(
771 sorted: &[f64],
772 quantile: f64,
773 epsilon: f64,
774 rng: &mut HpoRng,
775) -> Result<usize> {
776 if sorted.is_empty() {
777 return Err(OptimError::InvalidParameter(
778 "a quantile needs at least one observation".to_string(),
779 ));
780 }
781 if !(0.0..=1.0).contains(&quantile) {
782 return Err(OptimError::InvalidParameter(format!(
783 "the quantile must lie in [0, 1], got {quantile}"
784 )));
785 }
786 let target = quantile * (sorted.len() - 1) as f64;
787 let utilities: Vec<f64> = (0..sorted.len())
788 .map(|index| -((index as f64 - target).abs()))
789 .collect();
790 report_noisy_max_gumbel(&utilities, 1.0, epsilon, rng)
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use crate::privacy::private_hyperparameter_optimization::types::os_seeded_hpo_rng;
797 use std::collections::HashMap;
798
799 fn seeded(seed: u64) -> HpoRng {
800 scirs2_core::random::Random::seed(seed)
801 }
802
803 fn frequencies<F: FnMut() -> usize>(mut draw: F, candidates: usize, trials: usize) -> Vec<f64> {
805 let mut counts = vec![0usize; candidates];
806 for _ in 0..trials {
807 counts[draw()] += 1;
808 }
809 counts
810 .into_iter()
811 .map(|count| count as f64 / trials as f64)
812 .collect()
813 }
814
815 #[test]
816 fn the_exponential_mechanism_is_not_an_exact_argmax() {
817 let utilities = [0.0, 0.1, 0.2, 1.0];
821 let mut non_argmax = 0usize;
822 for trial in 0..400u64 {
823 let index = match exponential_mechanism_index(&utilities, 1.0, 0.5, Some(trial)) {
824 Ok(index) => index,
825 Err(err) => panic!("selection failed: {err}"),
826 };
827 if index != 3 {
828 non_argmax += 1;
829 }
830 }
831 assert!(
832 non_argmax > 40,
833 "only {non_argmax}/400 draws deviated from the argmax; the choice is not private"
834 );
835 }
836
837 #[test]
838 fn a_large_epsilon_concentrates_on_the_argmax() {
839 let utilities = [0.0, 0.1, 0.2, 1.0];
840 let mut argmax_hits = 0usize;
841 for trial in 0..200u64 {
842 let index = match exponential_mechanism_index(&utilities, 1.0, 200.0, Some(trial)) {
843 Ok(index) => index,
844 Err(err) => panic!("selection failed: {err}"),
845 };
846 if index == 3 {
847 argmax_hits += 1;
848 }
849 }
850 assert!(
851 argmax_hits >= 195,
852 "only {argmax_hits}/200 draws hit the argmax at epsilon = 200"
853 );
854 }
855
856 #[test]
857 fn the_selection_distribution_matches_the_closed_form_weights() {
858 let utilities = [0.0f64, 1.0, 2.0];
860 let epsilon = 1.0;
861 let sensitivity = 1.0;
862 let weights: Vec<f64> = utilities
863 .iter()
864 .map(|utility| (epsilon * utility / (2.0 * sensitivity)).exp())
865 .collect();
866 let total: f64 = weights.iter().sum();
867 let expected: Vec<f64> = weights.iter().map(|weight| weight / total).collect();
868
869 let trials = 20_000usize;
870 let mut trial = 0u64;
871 let observed = frequencies(
872 || {
873 trial += 1;
874 match exponential_mechanism_index(&utilities, sensitivity, epsilon, Some(trial)) {
875 Ok(index) => index,
876 Err(err) => panic!("selection failed: {err}"),
877 }
878 },
879 utilities.len(),
880 trials,
881 );
882 for (index, (observed, expected)) in observed.iter().zip(expected.iter()).enumerate() {
883 assert!(
884 (observed - expected).abs() < 0.02,
885 "candidate {index}: observed {observed:.4} vs expected {expected:.4}"
886 );
887 }
888 }
889
890 #[test]
891 fn gumbel_report_noisy_max_matches_the_exponential_mechanism() {
892 let utilities = [0.0f64, 0.5, 1.0, 1.5];
895 let epsilon = 1.0;
896 let sensitivity = 1.0;
897 let trials = 20_000usize;
898
899 let mut trial = 0u64;
900 let exponential = frequencies(
901 || {
902 trial += 1;
903 match exponential_mechanism_index(&utilities, sensitivity, epsilon, Some(trial)) {
904 Ok(index) => index,
905 Err(err) => panic!("selection failed: {err}"),
906 }
907 },
908 utilities.len(),
909 trials,
910 );
911
912 let mut rng = seeded(12_345);
913 let gumbel = frequencies(
914 || match report_noisy_max_gumbel(&utilities, sensitivity, epsilon, &mut rng) {
915 Ok(index) => index,
916 Err(err) => panic!("selection failed: {err}"),
917 },
918 utilities.len(),
919 trials,
920 );
921
922 for (index, (left, right)) in exponential.iter().zip(gumbel.iter()).enumerate() {
923 assert!(
924 (left - right).abs() < 0.02,
925 "candidate {index}: exponential {left:.4} vs gumbel {right:.4}"
926 );
927 }
928 }
929
930 #[test]
931 fn laplace_report_noisy_max_favours_but_does_not_guarantee_the_argmax() {
932 let utilities = [0.0f64, 0.2, 1.0];
933 let mut rng = seeded(7);
934 let observed = frequencies(
935 || match report_noisy_max_laplace(&utilities, 1.0, 1.0, &mut rng) {
936 Ok(index) => index,
937 Err(err) => panic!("selection failed: {err}"),
938 },
939 utilities.len(),
940 5_000,
941 );
942 assert!(observed[2] > observed[0], "the argmax must be favoured");
943 assert!(
944 observed[0] > 0.02,
945 "a weak candidate must still be reachable"
946 );
947 }
948
949 #[test]
950 fn gaussian_selection_needs_a_delta_and_a_small_epsilon() {
951 let utilities = [0.0f64, 1.0];
952 let mut rng = seeded(3);
953 assert!(report_noisy_max_gaussian(&utilities, 1.0, 0.5, 0.0, &mut rng).is_err());
954 assert!(report_noisy_max_gaussian(&utilities, 1.0, 2.0, 1e-5, &mut rng).is_err());
955 assert!(report_noisy_max_gaussian(&utilities, 1.0, 0.5, 1e-5, &mut rng).is_ok());
956 }
957
958 #[test]
959 fn the_gaussian_sigma_matches_the_published_closed_form() {
960 for (delta, expected) in [
965 (1e-5f64, 4.844_805_262_605_389f64),
966 (1e-6, 5.298_802_526_850_474),
967 ] {
968 let sigma = match gaussian_sigma(1.0, 1.0, delta) {
969 Ok(sigma) => sigma,
970 Err(err) => panic!("sigma failed: {err}"),
971 };
972 assert!(
973 (sigma - expected).abs() < 1e-12,
974 "sigma({delta}) = {sigma}, expected {expected}"
975 );
976 }
977 let doubled = match gaussian_sigma(2.0, 1.0, 1e-5) {
979 Ok(sigma) => sigma,
980 Err(err) => panic!("sigma failed: {err}"),
981 };
982 assert!((doubled - 2.0 * 4.844_805_262_605_389).abs() < 1e-12);
983 let halved_epsilon = match gaussian_sigma(1.0, 0.5, 1e-5) {
984 Ok(sigma) => sigma,
985 Err(err) => panic!("sigma failed: {err}"),
986 };
987 assert!((halved_epsilon - 2.0 * 4.844_805_262_605_389).abs() < 1e-12);
988 }
989
990 #[test]
991 fn degenerate_selection_parameters_are_refused() {
992 let utilities = [0.0f64, 1.0];
993 assert!(exponential_mechanism_index(&[], 1.0, 1.0, Some(1)).is_err());
994 assert!(exponential_mechanism_index(&utilities, 0.0, 1.0, Some(1)).is_err());
995 assert!(exponential_mechanism_index(&utilities, 1.0, 0.0, Some(1)).is_err());
996 assert!(exponential_mechanism_index(&utilities, -1.0, 1.0, Some(1)).is_err());
997 assert!(exponential_mechanism_index(&[f64::NAN, 1.0], 1.0, 1.0, Some(1)).is_err());
998 }
999
1000 #[test]
1001 fn the_laplace_sampler_never_produces_an_infinity() {
1002 let mut rng = os_seeded_hpo_rng();
1003 for _ in 0..20_000 {
1004 let sample = match laplace_sample(&mut rng, 1.0) {
1005 Ok(sample) => sample,
1006 Err(err) => panic!("sampling failed: {err}"),
1007 };
1008 assert!(sample.is_finite(), "sample {sample} is not finite");
1009 }
1010 assert!(laplace_sample(&mut rng, 0.0).is_err());
1011 assert!(laplace_sample(&mut rng, f64::NAN).is_err());
1012 }
1013
1014 #[test]
1015 fn the_laplace_sampler_has_the_right_scale() {
1016 let mut rng = seeded(99);
1017 let scale = 2.0;
1018 let trials = 200_000usize;
1019 let mut absolute_total = 0.0;
1020 for _ in 0..trials {
1021 let sample = match laplace_sample(&mut rng, scale) {
1022 Ok(sample) => sample,
1023 Err(err) => panic!("sampling failed: {err}"),
1024 };
1025 absolute_total += sample.abs();
1026 }
1027 let mean_absolute = absolute_total / trials as f64;
1029 assert!(
1030 (mean_absolute - scale).abs() < 0.05,
1031 "E|X| = {mean_absolute}, expected {scale}"
1032 );
1033 }
1034
1035 #[test]
1036 fn the_gumbel_sampler_has_the_right_mean() {
1037 let mut rng = seeded(4_242);
1038 let trials = 200_000usize;
1039 let mut total = 0.0;
1040 for _ in 0..trials {
1041 total += gumbel_sample(&mut rng);
1042 }
1043 let mean = total / trials as f64;
1045 assert!((mean - 0.577_215_664_9).abs() < 0.02, "mean = {mean}");
1046 }
1047
1048 #[test]
1049 fn the_gaussian_sampler_has_the_right_standard_deviation() {
1050 let mut rng = seeded(24);
1051 let sigma = 3.0;
1052 let trials = 200_000usize;
1053 let mut total = 0.0;
1054 let mut total_squared = 0.0;
1055 for _ in 0..trials {
1056 let sample = match gaussian_sample(&mut rng, sigma) {
1057 Ok(sample) => sample,
1058 Err(err) => panic!("sampling failed: {err}"),
1059 };
1060 total += sample;
1061 total_squared += sample * sample;
1062 }
1063 let count = trials as f64;
1064 let mean = total / count;
1065 let observed = (total_squared / count - mean * mean).sqrt();
1066 assert!((mean).abs() < 0.05, "mean = {mean}");
1067 assert!((observed - sigma).abs() < 0.05, "sigma = {observed}");
1068 }
1069
1070 #[test]
1071 fn utility_functions_map_objectives_monotonically() {
1072 let linear: UtilityFunction<f64> = UtilityFunction::new();
1073 match linear.evaluate(2.0) {
1074 Ok(value) => assert!((value - 2.0).abs() < 1e-12),
1075 Err(err) => panic!("evaluate failed: {err}"),
1076 }
1077 assert!(linear.evaluate(f64::NAN).is_err());
1078 }
1079
1080 #[test]
1081 fn a_custom_utility_function_is_refused_rather_than_faked() {
1082 let mut utility: UtilityFunction<f64> = UtilityFunction::new();
1083 utility.set_function_type(UtilityFunctionType::Custom);
1084 assert!(utility.evaluate(1.0).is_err());
1085 }
1086
1087 #[test]
1088 fn the_logarithmic_utility_refuses_a_non_positive_objective() {
1089 let mut utility: UtilityFunction<f64> = UtilityFunction::new();
1090 utility.set_function_type(UtilityFunctionType::Logarithmic);
1091 assert!(utility.evaluate(0.0).is_err());
1092 assert!(utility.evaluate(-1.0).is_err());
1093 assert!(utility.evaluate(std::f64::consts::E).is_ok());
1094 }
1095
1096 #[test]
1097 fn multi_objective_scalarisation_checks_the_weight_count() {
1098 let mut utility: UtilityFunction<f64> = UtilityFunction::new();
1099 assert!(utility.evaluate_multi(&[1.0, 2.0]).is_err());
1100 utility.set_multi_objective_weights(Some(vec![0.5, 0.5]));
1101 match utility.evaluate_multi(&[1.0, 3.0]) {
1102 Ok(value) => assert!((value - 2.0).abs() < 1e-12),
1103 Err(err) => panic!("scalarisation failed: {err}"),
1104 }
1105 assert!(utility.evaluate_multi(&[1.0]).is_err());
1106 }
1107
1108 #[test]
1109 fn the_objective_sensitivity_must_be_declared() {
1110 let empty: SensitivityBounds<f64> = SensitivityBounds {
1111 global_sensitivity: HashMap::new(),
1112 local_sensitivity: HashMap::new(),
1113 smooth_sensitivity: HashMap::new(),
1114 };
1115 assert!(
1116 empty.objective_sensitivity().is_none(),
1117 "an undeclared sensitivity must not be guessed"
1118 );
1119
1120 let mut declared = HashMap::new();
1121 declared.insert(OBJECTIVE_SENSITIVITY_KEY.to_string(), 0.25f64);
1122 let bounds: SensitivityBounds<f64> = SensitivityBounds {
1123 global_sensitivity: declared,
1124 local_sensitivity: HashMap::new(),
1125 smooth_sensitivity: HashMap::new(),
1126 };
1127 assert_eq!(bounds.objective_sensitivity(), Some(0.25));
1128 }
1129
1130 #[test]
1131 fn the_selection_mechanism_charges_and_reports_its_epsilon() {
1132 let mut mechanism: SelectionMechanism<f64> = SelectionMechanism::new();
1133 mechanism.seed_for_tests(11);
1134 let outcome = match mechanism.select_index(&[0.0, 0.5, 1.0]) {
1135 Ok(outcome) => outcome,
1136 Err(err) => panic!("selection failed: {err}"),
1137 };
1138 assert!(outcome.index < 3);
1139 assert_eq!(outcome.epsilon_spent, 1.0);
1140 assert_eq!(outcome.delta_spent, 0.0);
1141 assert_eq!(outcome.mechanism, "exponential_mechanism");
1142 assert_eq!(mechanism.epsilon_spent(), 1.0);
1143
1144 let _ = mechanism.select_index(&[0.0, 1.0]);
1145 assert_eq!(
1146 mechanism.epsilon_spent(),
1147 2.0,
1148 "each selection must be charged"
1149 );
1150 assert_eq!(mechanism.selection_count(), 2);
1151 }
1152
1153 #[test]
1154 fn a_sparse_vector_selection_is_refused_with_a_pointer_to_the_real_primitive() {
1155 let mut mechanism: SelectionMechanism<f64> = SelectionMechanism::new();
1156 mechanism.set_mechanism_type(HyperparameterNoiseMechanism::SparseVector);
1157 let message = match mechanism.select_index(&[0.0, 1.0]) {
1158 Err(err) => err.to_string(),
1159 Ok(_) => panic!("the sparse vector technique is not a selection mechanism"),
1160 };
1161 assert!(message.contains("SparseVectorMechanism"), "got: {message}");
1162 }
1163
1164 #[test]
1165 fn every_pure_epsilon_mechanism_selects_a_valid_index() {
1166 for mechanism_type in [
1167 HyperparameterNoiseMechanism::Exponential,
1168 HyperparameterNoiseMechanism::NoisyMax,
1169 HyperparameterNoiseMechanism::Laplace,
1170 ] {
1171 let mut mechanism: SelectionMechanism<f64> = SelectionMechanism::new();
1172 mechanism.set_mechanism_type(mechanism_type);
1173 mechanism.seed_for_tests(5);
1174 let outcome = match mechanism.select_index(&[0.0, 0.5, 1.0]) {
1175 Ok(outcome) => outcome,
1176 Err(err) => panic!("{mechanism_type:?} failed: {err}"),
1177 };
1178 assert!(outcome.index < 3);
1179 assert_eq!(outcome.delta_spent, 0.0);
1180 }
1181 }
1182
1183 #[test]
1184 fn a_threshold_restricts_the_candidate_set_and_remaps_the_index() {
1185 let mut mechanism: SelectionMechanism<f64> = SelectionMechanism::new();
1186 mechanism.seed_for_tests(2);
1187 let mut params = mechanism.selection_params().clone();
1188 params.threshold = Some(0.9);
1189 let ok = mechanism.set_selection_parameters(params);
1190 assert!(ok.is_ok());
1191
1192 for _ in 0..50 {
1193 let outcome = match mechanism.select_index(&[0.0, 0.5, 1.0, 0.95]) {
1194 Ok(outcome) => outcome,
1195 Err(err) => panic!("selection failed: {err}"),
1196 };
1197 assert!(
1198 outcome.index == 2 || outcome.index == 3,
1199 "index {} is below the threshold",
1200 outcome.index
1201 );
1202 }
1203
1204 let mut params = mechanism.selection_params().clone();
1205 params.threshold = Some(5.0);
1206 let ok = mechanism.set_selection_parameters(params);
1207 assert!(ok.is_ok());
1208 assert!(
1209 mechanism.select_index(&[0.0, 1.0]).is_err(),
1210 "an unreachable threshold must be an error"
1211 );
1212 }
1213
1214 #[test]
1215 fn noisy_summary_statistics_track_the_true_values_and_are_not_exact() {
1216 let values: Vec<f64> = (0..200).map(|index| index as f64 / 200.0).collect();
1217 let mut rng = seeded(31);
1218 let released = match noisy_summary_statistics(&values, 1.0, 4.0, &mut rng) {
1219 Ok(released) => released,
1220 Err(err) => panic!("summary failed: {err}"),
1221 };
1222 assert!(
1223 (released.epsilon_spent - 4.0).abs() < 1e-12,
1224 "the summary spent {} of a 4.0 budget",
1225 released.epsilon_spent
1226 );
1227 let summary = released.statistics;
1228 let true_mean = values.iter().sum::<f64>() / values.len() as f64;
1229 assert!(
1230 (summary.noisy_mean - true_mean).abs() < 0.2,
1231 "noisy mean {} vs true {true_mean}",
1232 summary.noisy_mean
1233 );
1234 assert!(
1235 summary.noisy_std > 0.0,
1236 "the standard deviation must not be the hardcoded zero it used to be"
1237 );
1238 assert_ne!(
1239 summary.noisy_median, summary.noisy_mean,
1240 "the median must not be a copy of the mean"
1241 );
1242 assert_eq!(summary.noisy_quantiles.len(), 3);
1243 assert!(summary
1244 .noisy_quantiles
1245 .iter()
1246 .all(|(_, value)| { (0.0..=1.0).contains(value) }));
1247 }
1248
1249 #[test]
1250 fn noisy_summary_statistics_reject_degenerate_inputs() {
1251 let mut rng = seeded(1);
1252 assert!(noisy_summary_statistics::<f64>(&[], 1.0, 1.0, &mut rng).is_err());
1253 assert!(noisy_summary_statistics(&[1.0f64], 0.0, 1.0, &mut rng).is_err());
1254 assert!(noisy_summary_statistics(&[1.0f64], 1.0, 0.0, &mut rng).is_err());
1255 }
1256
1257 #[test]
1258 fn the_private_median_concentrates_near_the_true_median() {
1259 let values: Vec<f64> = (0..101).map(|index| index as f64).collect();
1260 let mut rng = seeded(77);
1261 let mut sorted = values.clone();
1262 sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
1263 let mut total_offset = 0.0;
1264 for _ in 0..500 {
1265 let index = match private_quantile_index(&sorted, 0.5, 2.0, &mut rng) {
1266 Ok(index) => index,
1267 Err(err) => panic!("quantile failed: {err}"),
1268 };
1269 total_offset += (index as f64 - 50.0).abs();
1270 }
1271 let mean_offset = total_offset / 500.0;
1272 assert!(
1273 mean_offset < 5.0,
1274 "the private median drifted {mean_offset} ranks from the truth"
1275 );
1276 assert!(
1277 mean_offset > 0.0,
1278 "an exact median would leak the selection"
1279 );
1280 }
1281}