Skip to main content

optirs_core/privacy/private_hyperparameter_optimization/
selection.rs

1//! Differentially private selection of hyperparameter configurations.
2//!
3//! # The defect this replaces
4//!
5//! `HyperparameterNoiseMechanism` was stored on `PrivateHPOConfig` and never
6//! matched on anywhere in the crate: no exponential mechanism, no
7//! report-noisy-max, no noise on the choice at any point.
8//! `PrivateResultsAggregator::aggregate_results` sorted the evaluations exactly
9//! and returned the exact top five, and `optimize()` tracked the exact argmax.
10//!
11//! Private hyperparameter optimization is *entirely* about privatising the
12//! selection step (Liu & Talwar, STOC 2019; Chaudhuri, Monteleoni & Sarwate,
13//! JMLR 2011), so an exact argmax over utilities computed from private data
14//! leaks the selection and provides no guarantee for the chosen configuration.
15//!
16//! # What is implemented
17//!
18//! * The **exponential mechanism** (McSherry & Talwar, FOCS 2007): index `i` is
19//!   returned with probability proportional to
20//!   `exp(epsilon * u_i / (2 * Delta_u))`. The weighting itself is delegated to
21//!   the crate's audited
22//!   [`crate::privacy::noise_mechanisms::ExponentialMechanism`] rather than
23//!   reimplemented here.
24//! * **Report-noisy-max** with Gumbel noise, which is *equivalent in
25//!   distribution* to the exponential mechanism -- asserted by a test in this
26//!   module -- and with Laplace noise at scale `2 Delta_u / epsilon`.
27//! * A **Gaussian** argmax at scale
28//!   `sqrt(2 ln(1.25/delta)) * 2 Delta_u / epsilon`, which requires a delta and
29//!   errors when none is configured.
30//! * `SparseVector` selection is refused: the sparse vector technique answers a
31//!   *stream* of threshold queries and is not a one-shot selection primitive.
32//!   Use [`crate::privacy::noise_mechanisms::SparseVectorMechanism`].
33//!
34//! Every selection reports the epsilon it consumed so the caller can charge it.
35
36use 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
47/// Key under which the objective's global sensitivity is looked up in
48/// [`SensitivityBounds::global_sensitivity`].
49pub const OBJECTIVE_SENSITIVITY_KEY: &str = "objective";
50
51/// Outcome of one private selection.
52#[derive(Debug, Clone)]
53pub struct SelectionOutcome {
54    /// Index of the selected candidate.
55    pub index: usize,
56    /// Epsilon consumed by this selection.
57    pub epsilon_spent: f64,
58    /// Delta consumed by this selection (0 for the pure-epsilon mechanisms).
59    pub delta_spent: f64,
60    /// Name of the mechanism that produced the choice.
61    pub mechanism: &'static str,
62}
63
64/// Draw a uniform sample strictly inside `(0, 1)`.
65///
66/// `gen_range(0.0..1.0)` can return exactly `0.0`, and `ln(0)` is `-inf`, which
67/// silently poisons every noise sample derived from it.
68fn 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
79/// One Laplace sample with scale `b`.
80pub 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
91/// One standard Gumbel sample.
92pub fn gumbel_sample(rng: &mut HpoRng) -> f64 {
93    let uniform = open_unit_sample(rng);
94    -(-uniform.ln()).ln()
95}
96
97/// One Gaussian sample with standard deviation `sigma`, by Box-Muller.
98pub 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
109/// Analytic Gaussian-mechanism standard deviation for `(epsilon, delta)`.
110///
111/// `sigma = sqrt(2 ln(1.25/delta)) * sensitivity / epsilon` (Dwork & Roth,
112/// Thm. A.1), valid for `epsilon <= 1`.
113pub 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
138/// Convert `utilities` to `f64`, rejecting anything non-finite.
139fn 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
165/// The index of the largest value, without noise.
166fn 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
184/// Select an index with the exponential mechanism.
185///
186/// Delegates the weighting to the crate's audited value-selecting
187/// [`ValueExponentialMechanism`]: the candidate set is the index range encoded
188/// as `f64` (exact for any realistic candidate count) and the quality function
189/// is a lookup into `utilities`.
190pub 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
222/// The exponential mechanism's selection probabilities, in closed form.
223///
224/// `P(i) = exp(epsilon * u_i / (2 Delta)) / sum_j exp(epsilon * u_j / (2 Delta))`,
225/// computed with the max subtracted for numerical stability. Used to report the
226/// probability the mechanism actually assigned to the configuration it returned,
227/// instead of a placeholder confidence.
228pub 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
267/// The Laplace scale [`noisy_summary_statistics`] uses for the mean release.
268///
269/// Exposed so a caller can widen a confidence interval by the noise it added,
270/// rather than reporting a sampling-only interval as if the release were exact.
271pub 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
290/// Report-noisy-max with Gumbel noise.
291///
292/// Adding `Gumbel(2 Delta / epsilon)` noise to each utility and reporting the
293/// argmax is *exactly* the exponential mechanism (the Gumbel-max trick), so it
294/// carries the same `epsilon`-DP guarantee.
295pub 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
319/// Report-noisy-max with Laplace noise at scale `2 Delta / epsilon`.
320pub 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
344/// Argmax after adding Gaussian noise calibrated for `(epsilon, delta)`.
345pub 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    /// Map a raw objective value to a selection utility.
362    ///
363    /// The utility must be monotone in the objective for the exponential
364    /// mechanism to select "good" configurations, and its sensitivity is what
365    /// `Delta_u` bounds.
366    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    /// Scalarise a multi-objective value with the configured weights.
418    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    /// The declared global sensitivity of the objective.
452    ///
453    /// Looks for [`OBJECTIVE_SENSITIVITY_KEY`] and otherwise takes the largest
454    /// declared global sensitivity. Returns `None` when nothing is declared:
455    /// guessing a sensitivity would silently invalidate every epsilon derived
456    /// from it, so the caller must refuse instead.
457    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    /// The smooth-sensitivity beta declared for a parameter, if any.
471    pub fn smooth_beta(&self, parameter: &str) -> Option<T> {
472        self.smooth_sensitivity
473            .get(parameter)
474            .map(|params| params.beta)
475    }
476
477    /// The local sensitivity interval declared for a parameter, if any.
478    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    /// Create a mechanism with explicit parameters.
485    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    /// Replace the RNG with a deterministic one (tests only).
498    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    /// Reseed from OS entropy.
504    pub fn reseed_from_os(&mut self) {
505        self.set_rng(os_seeded_hpo_rng());
506        self.set_test_seed(None);
507    }
508
509    /// Privately select the index of a candidate from its utilities.
510    ///
511    /// The utilities are consumed as supplied: pass them through
512    /// [`UtilityFunction::evaluate`] first if the objective needs mapping.
513    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        // A configured threshold discards candidates whose utility is below it
538        // *before* the mechanism runs. This is only sound when the threshold is
539        // public; it is documented as such on `SelectionParameters::threshold`.
540        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
617/// Human-readable mechanism name.
618pub 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
628/// Quantiles released by [`noisy_summary_statistics`], in order.
629///
630/// The median is the `0.5` entry of this list; it is **not** released a second
631/// time, because a second release would be a second query against the same data
632/// and would have to be paid for separately.
633pub const SUMMARY_QUANTILES: [f64; 3] = [0.25, 0.5, 0.75];
634
635/// A differentially private summary, together with what it actually cost.
636///
637/// `epsilon_spent` is accumulated as each release is made, rather than asserted
638/// in a comment, so a caller charges exactly what the code path consumed.
639#[derive(Debug, Clone)]
640pub struct NoisySummary<T: Float + Debug + Send + Sync + 'static> {
641    /// The released statistics.
642    pub statistics: SummaryStatistics<T>,
643    /// Total epsilon consumed by every release in this summary.
644    pub epsilon_spent: f64,
645    /// Laplace scale used for the mean release, so a caller can widen a
646    /// confidence interval by the noise that was added.
647    pub mean_noise_scale: f64,
648}
649
650/// Differentially private summary statistics of the observed objectives.
651///
652/// # Budget split
653///
654/// `epsilon` is divided into three equal shares -- mean, standard deviation and
655/// quantiles -- and the quantile share is divided again across
656/// [`SUMMARY_QUANTILES`]. The releases compose linearly, and the total is
657/// returned in [`NoisySummary::epsilon_spent`], which is asserted to equal
658/// `epsilon` by a test in this module.
659///
660/// # Sensitivities
661///
662/// With `R = value_range` the public a-priori range of one observation and `n`
663/// observations, under one substitution:
664///
665/// * **mean**: `|Delta mean| <= R / n`.
666/// * **variance**: `var = (1/n) sum x_i^2 - mean^2`; the first term moves by at
667///   most `R^2/n` and `mean^2` by at most `2R(R/n) + (R/n)^2`, so
668///   `|Delta var| <= 4 R^2 / n` for `n >= 1`. Since `|sqrt(a) - sqrt(b)| <=
669///   sqrt(|a - b|)` for non-negative `a, b`, the standard deviation has
670///   `|Delta std| <= 2 R / sqrt(n)`. That (deliberately loose) bound is what
671///   calibrates the noise, not the tighter-looking `R / sqrt(n)`.
672/// * **quantiles**: released by the exponential mechanism over the order
673///   statistics with rank utility, whose sensitivity is exactly 1.
674pub 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    // Mean: sensitivity R / n.
702    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    // Standard deviation: sensitivity 2 R / sqrt(n), derived above.
708    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    // Quantiles, including the median, by the exponential mechanism over the
718    // order statistics (Smith 2011). The median is taken from this loop and is
719    // not released a second time.
720    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
765/// Exponential-mechanism index of a quantile over a sorted sample.
766///
767/// The utility of order statistic `i` is `-|i - q * (n - 1)|`, whose
768/// sensitivity is 1 (replacing one observation shifts every rank by at most
769/// one). This is the standard private-quantile construction (Smith 2011).
770fn 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    /// Empirical selection frequencies over `trials` draws.
804    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        // Regression for the core finding: selection used to be the exact
818        // argmax, which leaks the choice. With a small epsilon the mechanism
819        // must sometimes return a non-optimal candidate.
820        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        // P(i) = exp(eps u_i / 2 Delta) / sum_j exp(eps u_j / 2 Delta).
859        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        // The Gumbel-max trick makes these two mechanisms identical in
893        // distribution; agreement is a genuine cross-check of both.
894        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        // sqrt(2 ln(1.25/delta)) * sensitivity / epsilon, cross-checked against
961        // an independent evaluation of the same expression in Python:
962        //   sqrt(2 ln(1.25/1e-5)) = 4.844805262605389
963        //   sqrt(2 ln(1.25/1e-6)) = 5.298802526850474
964        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        // The scale is linear in the sensitivity and inverse in epsilon.
978        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        // E|Lap(b)| = b.
1028        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        // E[Gumbel(0,1)] = Euler-Mascheroni constant.
1044        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}