Skip to main content

uncertain_numerics/
active_design.rs

1use crate::{
2    ActiveDesignError, BayesianQuadrature, ScalarNormalPosterior, VarianceReductionAcquisition,
3};
4
5/// Reason a sequential active Bayesian-quadrature run terminated.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ActiveTermination {
8    /// The posterior integral variance reached the requested tolerance.
9    VarianceToleranceReached,
10    /// The configured budget of new function evaluations was exhausted.
11    EvaluationBudgetReached,
12    /// No unevaluated candidates remain.
13    CandidatesExhausted,
14}
15
16/// One function evaluation selected by the active design.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct ActiveDesignStep {
19    point: f64,
20    value: f64,
21    predicted_variance_reduction: f64,
22    posterior_variance: f64,
23}
24
25impl ActiveDesignStep {
26    /// Selected evaluation point.
27    #[must_use]
28    pub const fn point(self) -> f64 {
29        self.point
30    }
31
32    /// Observed function value at the selected point.
33    #[must_use]
34    pub const fn value(self) -> f64 {
35        self.value
36    }
37
38    /// Predicted variance reduction before the point was evaluated.
39    #[must_use]
40    pub const fn predicted_variance_reduction(self) -> f64 {
41        self.predicted_variance_reduction
42    }
43
44    /// Posterior integral variance after adding this observation.
45    #[must_use]
46    pub const fn posterior_variance(self) -> f64 {
47        self.posterior_variance
48    }
49}
50
51/// Result of a sequential active Bayesian-quadrature run.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ActiveDesignResult {
54    nodes: Vec<f64>,
55    values: Vec<f64>,
56    remaining_candidates: Vec<f64>,
57    steps: Vec<ActiveDesignStep>,
58    posterior: ScalarNormalPosterior,
59    termination: ActiveTermination,
60}
61
62impl ActiveDesignResult {
63    /// Final observation nodes, including actively selected points.
64    #[must_use]
65    pub fn nodes(&self) -> &[f64] {
66        &self.nodes
67    }
68
69    /// Final observed function values.
70    #[must_use]
71    pub fn values(&self) -> &[f64] {
72        &self.values
73    }
74
75    /// Candidates not selected during this run.
76    #[must_use]
77    pub fn remaining_candidates(&self) -> &[f64] {
78        &self.remaining_candidates
79    }
80
81    /// Ordered active-evaluation history.
82    #[must_use]
83    pub fn steps(&self) -> &[ActiveDesignStep] {
84        &self.steps
85    }
86
87    /// Final integral posterior.
88    #[must_use]
89    pub const fn posterior(&self) -> ScalarNormalPosterior {
90        self.posterior
91    }
92
93    /// Reason the active design stopped.
94    #[must_use]
95    pub const fn termination(&self) -> ActiveTermination {
96        self.termination
97    }
98}
99
100/// Sequential active Bayesian quadrature over a finite candidate set.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct ActiveBayesianQuadrature {
103    quadrature: BayesianQuadrature,
104    acquisition: VarianceReductionAcquisition,
105}
106
107impl ActiveBayesianQuadrature {
108    /// Construct active Bayesian quadrature from one consistent kernel/measure setup.
109    #[must_use]
110    pub const fn new(quadrature: BayesianQuadrature) -> Self {
111        let acquisition = VarianceReductionAcquisition::new(
112            quadrature.kernel(),
113            quadrature.measure(),
114            quadrature.jitter(),
115        );
116        Self {
117            quadrature,
118            acquisition,
119        }
120    }
121
122    /// Return the underlying Bayesian-quadrature configuration.
123    #[must_use]
124    pub const fn quadrature(&self) -> BayesianQuadrature {
125        self.quadrature
126    }
127
128    /// Run sequential active selection and function evaluation.
129    ///
130    /// The loop stops when the posterior integral variance is no greater than
131    /// `variance_tolerance`, when `max_new_evaluations` points have been added,
132    /// or when the finite candidate set is exhausted.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`ActiveDesignError`] for invalid stopping tolerance, non-finite
137    /// candidates or function evaluations, candidate-selection failures, or
138    /// posterior-construction failures.
139    pub fn run<F>(
140        &self,
141        initial_nodes: &[f64],
142        initial_values: &[f64],
143        candidates: &[f64],
144        max_new_evaluations: usize,
145        variance_tolerance: f64,
146        mut function: F,
147    ) -> Result<ActiveDesignResult, ActiveDesignError>
148    where
149        F: FnMut(f64) -> f64,
150    {
151        if !variance_tolerance.is_finite() {
152            return Err(ActiveDesignError::NonFiniteVarianceTolerance);
153        }
154        if variance_tolerance < 0.0 {
155            return Err(ActiveDesignError::NegativeVarianceTolerance);
156        }
157        if candidates.iter().any(|candidate| !candidate.is_finite()) {
158            return Err(ActiveDesignError::NonFiniteCandidate);
159        }
160
161        let mut nodes = initial_nodes.to_vec();
162        let mut values = initial_values.to_vec();
163        let mut remaining_candidates = candidates.to_vec();
164        let mut steps = Vec::new();
165        let mut posterior = self.quadrature.posterior(&nodes, &values)?;
166
167        if posterior.variance() <= variance_tolerance {
168            return Ok(ActiveDesignResult {
169                nodes,
170                values,
171                remaining_candidates,
172                steps,
173                posterior,
174                termination: ActiveTermination::VarianceToleranceReached,
175            });
176        }
177
178        for _ in 0..max_new_evaluations {
179            if remaining_candidates.is_empty() {
180                return Ok(ActiveDesignResult {
181                    nodes,
182                    values,
183                    remaining_candidates,
184                    steps,
185                    posterior,
186                    termination: ActiveTermination::CandidatesExhausted,
187                });
188            }
189
190            let selected = self
191                .acquisition
192                .select_best(&nodes, &remaining_candidates)?;
193            let point = selected.point();
194            let value = function(point);
195            if !value.is_finite() {
196                return Err(ActiveDesignError::NonFiniteFunctionValue);
197            }
198
199            remaining_candidates.remove(selected.index());
200            nodes.push(point);
201            values.push(value);
202            posterior = self.quadrature.posterior(&nodes, &values)?;
203            steps.push(ActiveDesignStep {
204                point,
205                value,
206                predicted_variance_reduction: selected.variance_reduction(),
207                posterior_variance: posterior.variance(),
208            });
209
210            if posterior.variance() <= variance_tolerance {
211                return Ok(ActiveDesignResult {
212                    nodes,
213                    values,
214                    remaining_candidates,
215                    steps,
216                    posterior,
217                    termination: ActiveTermination::VarianceToleranceReached,
218                });
219            }
220        }
221
222        let termination = if remaining_candidates.is_empty() {
223            ActiveTermination::CandidatesExhausted
224        } else {
225            ActiveTermination::EvaluationBudgetReached
226        };
227
228        Ok(ActiveDesignResult {
229            nodes,
230            values,
231            remaining_candidates,
232            steps,
233            posterior,
234            termination,
235        })
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::{ActiveBayesianQuadrature, ActiveTermination};
242    use crate::{ActiveDesignError, BayesianQuadrature, GaussianMeasure, RbfKernel};
243
244    fn fixture() -> ActiveBayesianQuadrature {
245        let kernel = RbfKernel::new(1.0, 1.0).expect("kernel parameters are valid");
246        let measure = GaussianMeasure::new(0.0, 1.0).expect("measure parameters are valid");
247        ActiveBayesianQuadrature::new(BayesianQuadrature::new(kernel, measure, 1.0e-12))
248    }
249
250    #[test]
251    fn sequential_run_adds_selected_candidates_and_removes_them() {
252        let active = fixture();
253        let result = active
254            .run(
255                &[-1.0, 1.0],
256                &[1.0, 1.0],
257                &[-0.5, 0.0, 0.5, 2.0],
258                2,
259                0.0,
260                |x| x * x,
261            )
262            .expect("active design should be valid");
263
264        assert_eq!(result.nodes().len(), 4);
265        assert_eq!(result.values().len(), 4);
266        assert_eq!(result.steps().len(), 2);
267        assert_eq!(result.remaining_candidates().len(), 2);
268        for step in result.steps() {
269            assert!(result.nodes().contains(&step.point()));
270            assert!(!result.remaining_candidates().contains(&step.point()));
271            assert!(step.predicted_variance_reduction() >= 0.0);
272        }
273    }
274
275    #[test]
276    fn posterior_variance_is_non_increasing_across_active_steps() {
277        let active = fixture();
278        let initial = active
279            .quadrature()
280            .posterior(&[-1.0, 1.0], &[1.0, 1.0])
281            .expect("initial posterior is valid");
282        let result = active
283            .run(
284                &[-1.0, 1.0],
285                &[1.0, 1.0],
286                &[-2.0, -0.5, 0.0, 0.5, 2.0],
287                3,
288                0.0,
289                f64::cos,
290            )
291            .expect("active design should be valid");
292
293        let mut previous = initial.variance();
294        for step in result.steps() {
295            assert!(step.posterior_variance() <= previous + 1.0e-12);
296            previous = step.posterior_variance();
297        }
298    }
299
300    #[test]
301    fn stops_immediately_when_variance_tolerance_is_already_met() {
302        let active = fixture();
303        let initial = active
304            .quadrature()
305            .posterior(&[-1.0, 0.0, 1.0], &[1.0, 0.0, 1.0])
306            .expect("initial posterior is valid");
307        let result = active
308            .run(
309                &[-1.0, 0.0, 1.0],
310                &[1.0, 0.0, 1.0],
311                &[-0.5, 0.5],
312                5,
313                initial.variance(),
314                |x| x * x,
315            )
316            .expect("active design should be valid");
317
318        assert_eq!(
319            result.termination(),
320            ActiveTermination::VarianceToleranceReached
321        );
322        assert!(result.steps().is_empty());
323    }
324
325    #[test]
326    fn reports_evaluation_budget_termination() {
327        let active = fixture();
328        let result = active
329            .run(&[-1.0, 1.0], &[1.0, 1.0], &[-0.5, 0.0, 0.5], 1, 0.0, |x| {
330                x * x
331            })
332            .expect("active design should be valid");
333
334        assert_eq!(
335            result.termination(),
336            ActiveTermination::EvaluationBudgetReached
337        );
338        assert_eq!(result.steps().len(), 1);
339    }
340
341    #[test]
342    fn reports_candidate_exhaustion() {
343        let active = fixture();
344        let result = active
345            .run(&[-1.0, 1.0], &[1.0, 1.0], &[0.0], 3, 0.0, |x| x * x)
346            .expect("active design should be valid");
347
348        assert_eq!(result.termination(), ActiveTermination::CandidatesExhausted);
349        assert_eq!(result.steps().len(), 1);
350        assert!(result.remaining_candidates().is_empty());
351    }
352
353    #[test]
354    fn rejects_invalid_tolerance_and_function_values() {
355        let active = fixture();
356        assert_eq!(
357            active.run(&[-1.0, 1.0], &[1.0, 1.0], &[0.0], 1, -1.0, |x| x),
358            Err(ActiveDesignError::NegativeVarianceTolerance)
359        );
360        assert_eq!(
361            active.run(&[-1.0, 1.0], &[1.0, 1.0], &[0.0], 1, 0.0, |_| f64::NAN),
362            Err(ActiveDesignError::NonFiniteFunctionValue)
363        );
364    }
365}