Skip to main content

scirs2_optimize/bayesian/
constrained_bo.rs

1//! Constrained Bayesian Optimization.
2//!
3//! Extends the standard Bayesian optimization framework to handle black-box
4//! constraints that are expensive to evaluate (e.g. safety constraints,
5//! physical feasibility conditions, computational limits).
6//!
7//! # Constraint Handling Strategies
8//!
9//! | Method | Description |
10//! |--------|-------------|
11//! | Expected Feasible Improvement (EFI) | EI weighted by probability of satisfying all constraints |
12//! | Probability of Feasibility (PoF) | Pure probability that a point is feasible |
13//! | Constrained EI (cEI) | EI penalised multiplicatively by PoF for each constraint |
14//! | Augmented Lagrangian BO | Adds constraint violation penalties to the GP posterior |
15//!
16//! # Example
17//!
18//! ```rust
19//! use scirs2_optimize::bayesian::constrained_bo::{
20//!     ConstrainedBo, ConstrainedBoConfig, BlackBoxConstraint,
21//! };
22//! use scirs2_core::ndarray::ArrayView1;
23//!
24//! // Minimize x[0]^2 + x[1]^2 subject to x[0] + x[1] >= 1.
25//! let obj = |x: &[f64]| x[0].powi(2) + x[1].powi(2);
26//!
27//! // Constraint g(x) <= 0 means "feasible". So g(x) = 1 - x[0] - x[1].
28//! let con = BlackBoxConstraint {
29//!     name: "sum_geq_1".into(),
30//!     // returns positive => infeasible, <= 0 => feasible.
31//!     evaluate: Box::new(|x: &[f64]| 1.0 - x[0] - x[1]),
32//! };
33//!
34//! let config = ConstrainedBoConfig {
35//!     n_initial: 5,
36//!     seed: Some(42),
37//!     ..Default::default()
38//! };
39//!
40//! let mut cbo = ConstrainedBo::new(
41//!     vec![(-2.0_f64, 2.0_f64), (-2.0_f64, 2.0_f64)],
42//!     vec![con],
43//!     config,
44//! ).expect("create");
45//!
46//! let result = cbo.optimize(obj, 20).expect("opt");
47//! println!("Best feasible x: {:?}  f: {:.4}", result.x_best, result.f_best);
48//! ```
49
50use scirs2_core::ndarray::{Array1, Array2};
51use scirs2_core::random::rngs::StdRng;
52use scirs2_core::random::{Rng, RngExt, SeedableRng};
53
54use crate::error::{OptimizeError, OptimizeResult};
55
56use super::acquisition::{AcquisitionFn, ExpectedImprovement};
57use super::gp::{GpSurrogate, GpSurrogateConfig, RbfKernel};
58use super::sampling::{generate_samples, SamplingStrategy};
59
60// ---------------------------------------------------------------------------
61// Normal CDF helper (duplicate from acquisition.rs to keep this module self-contained)
62// ---------------------------------------------------------------------------
63
64fn erf_approx(x: f64) -> f64 {
65    let a1 = 0.254829592_f64;
66    let a2 = -0.284496736_f64;
67    let a3 = 1.421413741_f64;
68    let a4 = -1.453152027_f64;
69    let a5 = 1.061405429_f64;
70    let p = 0.3275911_f64;
71    let sign = if x < 0.0 { -1.0 } else { 1.0 };
72    let x_abs = x.abs();
73    let t = 1.0 / (1.0 + p * x_abs);
74    let poly = (((a5 * t + a4) * t + a3) * t + a2) * t + a1;
75    sign * (1.0 - poly * t * (-x_abs * x_abs).exp())
76}
77
78fn norm_cdf(z: f64) -> f64 {
79    0.5 * (1.0 + erf_approx(z / std::f64::consts::SQRT_2))
80}
81
82fn norm_pdf(z: f64) -> f64 {
83    (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt()
84}
85
86// ---------------------------------------------------------------------------
87// Black-box constraint
88// ---------------------------------------------------------------------------
89
90/// A black-box constraint function.
91///
92/// The convention is: **g(x) <= 0 means feasible**, g(x) > 0 means infeasible.
93pub struct BlackBoxConstraint {
94    /// Human-readable name.
95    pub name: String,
96    /// Constraint function: returns a real value where <= 0 means feasible.
97    pub evaluate: Box<dyn Fn(&[f64]) -> f64 + Send + Sync>,
98}
99
100impl std::fmt::Debug for BlackBoxConstraint {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "BlackBoxConstraint {{ name: {:?} }}", self.name)
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Acquisition strategies for constrained BO
108// ---------------------------------------------------------------------------
109
110/// Strategy for handling constraints in the acquisition function.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub enum ConstrainedAcquisitionStrategy {
113    /// Expected Improvement weighted by the product of probabilities of
114    /// feasibility for all constraints (EFI = EI * Prod_i PoF_i).
115    ExpectedFeasibleImprovement,
116    /// Pure probability of feasibility: Prod_i PoF_i.
117    /// Useful for cold-start when no feasible point is known.
118    ProbabilityOfFeasibility,
119    /// Constrained EI: EI penalised multiplicatively by PoF.
120    ConstrainedExpectedImprovement,
121    /// Augmented Lagrangian BO: treats constraint violation as an additive
122    /// penalty to the objective surrogate.
123    AugmentedLagrangian {
124        /// Penalty weight for constraint violations.
125        penalty: f64,
126    },
127}
128
129impl Default for ConstrainedAcquisitionStrategy {
130    fn default() -> Self {
131        Self::ExpectedFeasibleImprovement
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Configuration
137// ---------------------------------------------------------------------------
138
139/// Configuration for constrained Bayesian optimization.
140#[derive(Clone)]
141pub struct ConstrainedBoConfig {
142    /// Acquisition strategy.
143    pub strategy: ConstrainedAcquisitionStrategy,
144    /// Number of initial random evaluations.
145    pub n_initial: usize,
146    /// Number of random candidates per acquisition optimization step.
147    pub acq_n_candidates: usize,
148    /// Exploration parameter xi for EI-based strategies.
149    pub xi: f64,
150    /// Minimum probability of feasibility to accept a point.
151    pub min_pof: f64,
152    /// Seed for reproducibility.
153    pub seed: Option<u64>,
154    /// Verbose output level.
155    pub verbose: usize,
156    /// GP configuration for the objective surrogate.
157    pub gp_config: GpSurrogateConfig,
158}
159
160impl Default for ConstrainedBoConfig {
161    fn default() -> Self {
162        Self {
163            strategy: ConstrainedAcquisitionStrategy::default(),
164            n_initial: 10,
165            acq_n_candidates: 300,
166            xi: 0.01,
167            min_pof: 0.0,
168            seed: None,
169            verbose: 0,
170            gp_config: GpSurrogateConfig {
171                noise_variance: 1e-4,
172                optimize_hyperparams: true,
173                ..Default::default()
174            },
175        }
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Observation record
181// ---------------------------------------------------------------------------
182
183/// A single evaluated point in constrained BO.
184#[derive(Debug, Clone)]
185pub struct ConstrainedObservation {
186    /// Input point.
187    pub x: Array1<f64>,
188    /// Objective value (may be infinite if infeasible and not evaluated).
189    pub y: f64,
190    /// Constraint values g_i(x) (negative = satisfied).
191    pub constraint_values: Vec<f64>,
192    /// Whether this point satisfies all constraints.
193    pub feasible: bool,
194}
195
196// ---------------------------------------------------------------------------
197// Result
198// ---------------------------------------------------------------------------
199
200/// Result of constrained Bayesian optimization.
201#[derive(Debug, Clone)]
202pub struct ConstrainedBoResult {
203    /// Best feasible input found. `None` if no feasible point was encountered.
204    pub x_best: Option<Array1<f64>>,
205    /// Best feasible objective value. `f64::INFINITY` if none found.
206    pub f_best: f64,
207    /// All observations.
208    pub observations: Vec<ConstrainedObservation>,
209    /// Number of total function evaluations (objective + constraints).
210    pub n_evals: usize,
211    /// History of best feasible values across iterations.
212    pub best_history: Vec<f64>,
213    /// Number of feasible observations found.
214    pub n_feasible: usize,
215}
216
217// ---------------------------------------------------------------------------
218// Probability of Feasibility surrogate
219// ---------------------------------------------------------------------------
220
221/// A GP surrogate trained on constraint observations, used to predict the
222/// probability that a new point will be feasible.
223struct ConstraintSurrogate {
224    /// GP modeling g_i(x).
225    gp: GpSurrogate,
226    /// Constraint index (for labeling).
227    idx: usize,
228}
229
230impl ConstraintSurrogate {
231    fn new(idx: usize, config: GpSurrogateConfig) -> Self {
232        let gp = GpSurrogate::new(Box::new(RbfKernel::default()), config);
233        Self { gp, idx }
234    }
235
236    /// Fit the constraint GP to observed constraint values.
237    fn fit(&mut self, x: &Array2<f64>, g: &Array1<f64>) -> OptimizeResult<()> {
238        if x.nrows() < 2 {
239            return Ok(()); // Not enough data.
240        }
241        self.gp.fit(x, g)
242    }
243
244    /// Predict the probability of feasibility (g(x) <= 0) at a candidate point.
245    ///
246    /// Uses the GP posterior: P(g(x) <= 0) = Phi(-mu(x) / sigma(x)).
247    fn predict_pof(&self, x: &scirs2_core::ndarray::ArrayView1<f64>) -> OptimizeResult<f64> {
248        if self.gp.n_train() == 0 {
249            // No data: return 0.5 (maximum uncertainty).
250            return Ok(0.5);
251        }
252        let (mu, sigma) = self.gp.predict_single(x)?;
253
254        if sigma < 1e-12 {
255            return Ok(if mu <= 0.0 { 1.0 } else { 0.0 });
256        }
257
258        // P(g <= 0) = P(Z <= -mu/sigma) where Z ~ N(0,1)
259        Ok(norm_cdf(-mu / sigma))
260    }
261
262    /// Predict the expected violation max(g(x), 0) at a candidate point.
263    fn predict_expected_violation(
264        &self,
265        x: &scirs2_core::ndarray::ArrayView1<f64>,
266    ) -> OptimizeResult<f64> {
267        if self.gp.n_train() == 0 {
268            return Ok(0.5);
269        }
270        let (mu, sigma) = self.gp.predict_single(x)?;
271        if sigma < 1e-12 {
272            return Ok(mu.max(0.0));
273        }
274        let z = mu / sigma;
275        // E[max(g, 0)] = mu * Phi(z) + sigma * phi(z)  (analogous to EI)
276        Ok((mu * norm_cdf(z) + sigma * norm_pdf(z)).max(0.0))
277    }
278
279    fn idx(&self) -> usize {
280        self.idx
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Acquisition: Probability of Feasibility (PoF)
286// ---------------------------------------------------------------------------
287
288/// Compute the joint probability of feasibility across all constraints.
289///
290/// PoF(x) = Prod_i P(g_i(x) <= 0)
291fn joint_pof(
292    x: &scirs2_core::ndarray::ArrayView1<f64>,
293    constraint_surrogates: &[ConstraintSurrogate],
294) -> OptimizeResult<f64> {
295    let mut pof = 1.0_f64;
296    for cs in constraint_surrogates {
297        pof *= cs.predict_pof(x)?;
298    }
299    Ok(pof)
300}
301
302// ---------------------------------------------------------------------------
303// Constrained BO struct
304// ---------------------------------------------------------------------------
305
306/// Bayesian optimizer with support for expensive black-box constraints.
307pub struct ConstrainedBo {
308    bounds: Vec<(f64, f64)>,
309    /// Black-box constraint functions.
310    constraints: Vec<BlackBoxConstraint>,
311    config: ConstrainedBoConfig,
312    /// GP surrogate for the objective.
313    obj_surrogate: GpSurrogate,
314    /// GP surrogate per constraint.
315    constraint_surrogates: Vec<ConstraintSurrogate>,
316    /// All observations.
317    observations: Vec<ConstrainedObservation>,
318    rng: StdRng,
319    f_best: f64,
320    best_history: Vec<f64>,
321    /// Current Lagrange multipliers for augmented Lagrangian strategy.
322    lagrange_multipliers: Vec<f64>,
323}
324
325impl ConstrainedBo {
326    /// Create a new constrained Bayesian optimizer.
327    pub fn new(
328        bounds: Vec<(f64, f64)>,
329        constraints: Vec<BlackBoxConstraint>,
330        config: ConstrainedBoConfig,
331    ) -> OptimizeResult<Self> {
332        if bounds.is_empty() {
333            return Err(OptimizeError::InvalidInput(
334                "bounds must not be empty".into(),
335            ));
336        }
337
338        let seed = config.seed.unwrap_or(0);
339        let rng = StdRng::seed_from_u64(seed);
340
341        let obj_surrogate =
342            GpSurrogate::new(Box::new(RbfKernel::default()), config.gp_config.clone());
343
344        let constraint_surrogates: Vec<_> = (0..constraints.len())
345            .map(|i| {
346                ConstraintSurrogate::new(
347                    i,
348                    GpSurrogateConfig {
349                        noise_variance: 1e-4,
350                        optimize_hyperparams: false,
351                        ..Default::default()
352                    },
353                )
354            })
355            .collect();
356
357        let n_constraints = constraints.len();
358
359        Ok(Self {
360            bounds,
361            constraints,
362            config,
363            obj_surrogate,
364            constraint_surrogates,
365            observations: Vec::new(),
366            rng,
367            f_best: f64::INFINITY,
368            best_history: Vec::new(),
369            lagrange_multipliers: vec![1.0; n_constraints],
370        })
371    }
372
373    /// Compute the acquisition value at a candidate point given the current surrogates.
374    fn acquisition_value(&self, x: &scirs2_core::ndarray::ArrayView1<f64>) -> OptimizeResult<f64> {
375        match self.config.strategy {
376            ConstrainedAcquisitionStrategy::ProbabilityOfFeasibility => {
377                joint_pof(x, &self.constraint_surrogates)
378            }
379
380            ConstrainedAcquisitionStrategy::ExpectedFeasibleImprovement => {
381                // EFI = EI * PoF
382                let pof = joint_pof(x, &self.constraint_surrogates)?;
383                if pof < 1e-10 {
384                    return Ok(0.0);
385                }
386                if self.obj_surrogate.n_train() == 0 {
387                    return Ok(pof);
388                }
389                let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
390                let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
391                Ok(ei_val * pof)
392            }
393
394            ConstrainedAcquisitionStrategy::ConstrainedExpectedImprovement => {
395                // cEI: EI penalised by PoF.
396                if self.obj_surrogate.n_train() == 0 {
397                    return joint_pof(x, &self.constraint_surrogates);
398                }
399                let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
400                let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
401                let pof = joint_pof(x, &self.constraint_surrogates)?;
402                Ok(ei_val * pof)
403            }
404
405            ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty } => {
406                // Penalised acquisition: EI(x) - lambda_i * E[max(g_i(x), 0)]
407                if self.obj_surrogate.n_train() == 0 {
408                    return joint_pof(x, &self.constraint_surrogates);
409                }
410                let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
411                let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
412
413                let mut total_penalty = 0.0_f64;
414                for (cs, &lam) in self
415                    .constraint_surrogates
416                    .iter()
417                    .zip(self.lagrange_multipliers.iter())
418                {
419                    let ev = cs.predict_expected_violation(x)?;
420                    total_penalty += (lam + penalty * ev) * ev;
421                }
422                Ok((ei_val - total_penalty).max(0.0))
423            }
424        }
425    }
426
427    /// Update Lagrange multipliers based on observed constraint violations
428    /// (for AugmentedLagrangian strategy).
429    fn update_lagrange_multipliers(&mut self) {
430        if let ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty } =
431            self.config.strategy
432        {
433            for (i, lam) in self.lagrange_multipliers.iter_mut().enumerate() {
434                // Average violation over all observations.
435                let avg_viol = self
436                    .observations
437                    .iter()
438                    .filter_map(|obs| obs.constraint_values.get(i).copied())
439                    .filter(|&v| v > 0.0)
440                    .sum::<f64>()
441                    / (self.observations.len().max(1) as f64);
442                *lam = (*lam + penalty * avg_viol).max(0.0);
443            }
444        }
445    }
446
447    /// Suggest the next point to evaluate.
448    pub fn ask(&mut self) -> OptimizeResult<Vec<f64>> {
449        let ndim = self.bounds.len();
450
451        // Initial random phase.
452        if self.observations.len() < self.config.n_initial {
453            let x: Vec<f64> = self
454                .bounds
455                .iter()
456                .map(|&(lo, hi)| lo + self.rng.random::<f64>() * (hi - lo))
457                .collect();
458            return Ok(x);
459        }
460
461        let candidates = generate_samples(
462            self.config.acq_n_candidates,
463            &self.bounds,
464            SamplingStrategy::LatinHypercube,
465            None,
466        )?;
467
468        let mut best_acq = f64::NEG_INFINITY;
469        let mut best_x: Vec<f64> = candidates.row(0).to_vec();
470
471        for i in 0..candidates.nrows() {
472            let row = candidates.row(i);
473
474            // Check minimum PoF filter.
475            let pof = joint_pof(&row, &self.constraint_surrogates)?;
476            if pof < self.config.min_pof {
477                continue;
478            }
479
480            let val = self.acquisition_value(&row)?;
481            if val > best_acq {
482                best_acq = val;
483                best_x = row.to_vec();
484            }
485        }
486
487        // If all candidates were filtered by min_pof, fall back to best by PoF.
488        if best_acq == f64::NEG_INFINITY {
489            for i in 0..candidates.nrows() {
490                let row = candidates.row(i);
491                let pof = joint_pof(&row, &self.constraint_surrogates)?;
492                if pof > best_acq {
493                    best_acq = pof;
494                    best_x = row.to_vec();
495                }
496            }
497        }
498
499        let _ = ndim; // used implicitly
500        Ok(best_x)
501    }
502
503    /// Evaluate a point: computes objective and all constraint values.
504    fn evaluate_point<F>(&self, x: &[f64], objective: &mut F) -> (f64, Vec<f64>)
505    where
506        F: FnMut(&[f64]) -> f64,
507    {
508        let y = objective(x);
509        let g_vals: Vec<f64> = self.constraints.iter().map(|c| (c.evaluate)(x)).collect();
510        (y, g_vals)
511    }
512
513    /// Record an observation.
514    pub fn tell(&mut self, x: Vec<f64>, y: f64, constraint_values: Vec<f64>) -> OptimizeResult<()> {
515        let ndim = self.bounds.len();
516        if x.len() != ndim {
517            return Err(OptimizeError::InvalidInput(format!(
518                "x has {} dims, expected {}",
519                x.len(),
520                ndim
521            )));
522        }
523
524        let feasible = constraint_values.iter().all(|&g| g <= 0.0);
525
526        if feasible && y < self.f_best {
527            self.f_best = y;
528        }
529        self.best_history.push(self.f_best);
530
531        self.observations.push(ConstrainedObservation {
532            x: Array1::from_vec(x.clone()),
533            y,
534            constraint_values: constraint_values.clone(),
535            feasible,
536        });
537
538        // Refit objective surrogate on feasible observations only.
539        let feasible_obs: Vec<&ConstrainedObservation> =
540            self.observations.iter().filter(|o| o.feasible).collect();
541
542        if feasible_obs.len() >= 2 {
543            let nf = feasible_obs.len();
544            let mut x_rows = Vec::with_capacity(nf * ndim);
545            let mut y_vec = Vec::with_capacity(nf);
546            for obs in &feasible_obs {
547                x_rows.extend(obs.x.iter().copied());
548                y_vec.push(obs.y);
549            }
550            let x_mat = Array2::from_shape_vec((nf, ndim), x_rows)
551                .map_err(|e| OptimizeError::ComputationError(format!("shape: {}", e)))?;
552            let y_arr = Array1::from_vec(y_vec);
553            self.obj_surrogate.fit(&x_mat, &y_arr)?;
554        }
555
556        // Refit constraint surrogates on all observations.
557        let n = self.observations.len();
558        if n >= 2 {
559            let mut x_rows = Vec::with_capacity(n * ndim);
560            for obs in &self.observations {
561                x_rows.extend(obs.x.iter().copied());
562            }
563            let x_mat = Array2::from_shape_vec((n, ndim), x_rows.clone())
564                .map_err(|e| OptimizeError::ComputationError(format!("shape: {}", e)))?;
565
566            for (i, cs) in self.constraint_surrogates.iter_mut().enumerate() {
567                let g_vec: Vec<f64> = self
568                    .observations
569                    .iter()
570                    .filter_map(|obs| obs.constraint_values.get(i).copied())
571                    .collect();
572                if g_vec.len() == n {
573                    let g_arr = Array1::from_vec(g_vec);
574                    let _ = cs.fit(&x_mat, &g_arr); // ignore fit errors for now
575                }
576            }
577        }
578
579        // Update Lagrange multipliers for AL strategy.
580        self.update_lagrange_multipliers();
581
582        Ok(())
583    }
584
585    /// Run the full constrained optimization loop.
586    pub fn optimize<F>(
587        &mut self,
588        mut objective: F,
589        n_calls: usize,
590    ) -> OptimizeResult<ConstrainedBoResult>
591    where
592        F: FnMut(&[f64]) -> f64,
593    {
594        for iter in 0..n_calls {
595            let x = self.ask()?;
596            let (y, g_vals) = self.evaluate_point(&x, &mut objective);
597
598            let feasible = g_vals.iter().all(|&g| g <= 0.0);
599            if self.config.verbose >= 2 {
600                println!(
601                    "[ConstrainedBo iter {}] x={:?} y={:.6} g={:?} feasible={}",
602                    iter, x, y, g_vals, feasible
603                );
604            }
605
606            self.tell(x, y, g_vals)?;
607        }
608
609        let n_feasible = self.observations.iter().filter(|o| o.feasible).count();
610
611        if self.config.verbose >= 1 {
612            println!(
613                "[ConstrainedBo] Done. Best feasible f={:.6}, {}/{} feasible.",
614                self.f_best,
615                n_feasible,
616                self.observations.len()
617            );
618        }
619
620        let best_feasible = self
621            .observations
622            .iter()
623            .filter(|o| o.feasible)
624            .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal));
625
626        let (x_best, f_best) = if let Some(obs) = best_feasible {
627            (Some(obs.x.clone()), obs.y)
628        } else {
629            (None, f64::INFINITY)
630        };
631
632        Ok(ConstrainedBoResult {
633            x_best,
634            f_best,
635            observations: self.observations.clone(),
636            n_evals: self.observations.len(),
637            best_history: self.best_history.clone(),
638            n_feasible,
639        })
640    }
641
642    /// Access all observations.
643    pub fn observations(&self) -> &[ConstrainedObservation] {
644        &self.observations
645    }
646
647    /// Returns the number of feasible observations found.
648    pub fn n_feasible(&self) -> usize {
649        self.observations.iter().filter(|o| o.feasible).count()
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Stand-alone acquisition function structs (for composability)
655// ---------------------------------------------------------------------------
656
657/// Expected Feasible Improvement: EI weighted by joint probability of feasibility.
658///
659/// EFI(x) = EI(x) * Prod_i P(g_i(x) <= 0)
660///
661/// This is the most commonly used constrained acquisition function.
662pub struct ExpectedFeasibleImprovement {
663    /// Current best *feasible* objective value.
664    pub f_best: f64,
665    /// Exploration parameter.
666    pub xi: f64,
667}
668
669impl ExpectedFeasibleImprovement {
670    pub fn new(f_best: f64, xi: f64) -> Self {
671        Self {
672            f_best,
673            xi: xi.max(0.0),
674        }
675    }
676
677    /// Evaluate EFI at `x` given the objective surrogate and a list of constraint surrogates.
678    pub fn evaluate(
679        &self,
680        x: &scirs2_core::ndarray::ArrayView1<f64>,
681        obj_gp: &GpSurrogate,
682        constraint_gps: &[&GpSurrogate],
683    ) -> OptimizeResult<f64> {
684        let ei = ExpectedImprovement::new(self.f_best, self.xi);
685        let ei_val = ei.evaluate(x, obj_gp)?;
686
687        // Compute PoF for each constraint GP.
688        let mut pof = 1.0_f64;
689        for cgp in constraint_gps {
690            if cgp.n_train() == 0 {
691                pof *= 0.5; // maximum uncertainty
692                continue;
693            }
694            let (mu_g, sigma_g) = cgp.predict_single(x)?;
695            let pof_i = if sigma_g < 1e-12 {
696                if mu_g <= 0.0 {
697                    1.0
698                } else {
699                    0.0
700                }
701            } else {
702                norm_cdf(-mu_g / sigma_g)
703            };
704            pof *= pof_i;
705        }
706
707        Ok(ei_val * pof)
708    }
709}
710
711/// Probability of Feasibility for a single constraint GP.
712///
713/// PoF_i(x) = P(g_i(x) <= 0) = Phi(-mu_i(x) / sigma_i(x))
714pub struct ProbabilityOfFeasibility;
715
716impl ProbabilityOfFeasibility {
717    /// Evaluate the probability that the constraint GP predicts g(x) <= 0.
718    pub fn evaluate(
719        x: &scirs2_core::ndarray::ArrayView1<f64>,
720        constraint_gp: &GpSurrogate,
721    ) -> OptimizeResult<f64> {
722        if constraint_gp.n_train() == 0 {
723            return Ok(0.5);
724        }
725        let (mu, sigma) = constraint_gp.predict_single(x)?;
726        if sigma < 1e-12 {
727            return Ok(if mu <= 0.0 { 1.0 } else { 0.0 });
728        }
729        Ok(norm_cdf(-mu / sigma))
730    }
731
732    /// Compute the joint probability of feasibility across multiple constraint GPs.
733    pub fn joint(
734        x: &scirs2_core::ndarray::ArrayView1<f64>,
735        constraint_gps: &[&GpSurrogate],
736    ) -> OptimizeResult<f64> {
737        let mut pof = 1.0_f64;
738        for cgp in constraint_gps {
739            pof *= Self::evaluate(x, cgp)?;
740        }
741        Ok(pof)
742    }
743}
744
745// ---------------------------------------------------------------------------
746// Convenience function
747// ---------------------------------------------------------------------------
748
749/// Run constrained Bayesian optimization with the default EFI strategy.
750///
751/// # Arguments
752///
753/// * `objective` - Objective function to minimize.
754/// * `constraints` - List of constraint functions (g(x) <= 0 = feasible).
755/// * `bounds` - Search space bounds.
756/// * `n_calls` - Number of evaluations.
757/// * `seed` - Optional random seed.
758pub fn constrained_optimize<F>(
759    objective: F,
760    constraints: Vec<BlackBoxConstraint>,
761    bounds: Vec<(f64, f64)>,
762    n_calls: usize,
763    seed: Option<u64>,
764) -> OptimizeResult<ConstrainedBoResult>
765where
766    F: FnMut(&[f64]) -> f64,
767{
768    let config = ConstrainedBoConfig {
769        n_initial: (n_calls / 4).max(3),
770        seed,
771        ..Default::default()
772    };
773    let mut cbo = ConstrainedBo::new(bounds, constraints, config)?;
774    cbo.optimize(objective, n_calls)
775}
776
777// ---------------------------------------------------------------------------
778// Tests
779// ---------------------------------------------------------------------------
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    fn make_constraint(threshold: f64) -> BlackBoxConstraint {
786        BlackBoxConstraint {
787            name: format!("x_leq_{}", threshold),
788            evaluate: Box::new(move |x: &[f64]| x[0] - threshold),
789        }
790    }
791
792    #[test]
793    fn test_unconstrained_like_run() {
794        // No constraints: should behave like standard BO.
795        let mut cbo = ConstrainedBo::new(
796            vec![(0.0_f64, 4.0_f64)],
797            vec![],
798            ConstrainedBoConfig {
799                n_initial: 3,
800                seed: Some(42),
801                ..Default::default()
802            },
803        )
804        .expect("create");
805        let result = cbo
806            .optimize(|x: &[f64]| (x[0] - 2.0_f64).powi(2), 8)
807            .expect("opt");
808        assert!(result.f_best.is_finite());
809        assert_eq!(result.n_evals, 8);
810    }
811
812    #[test]
813    fn test_constraint_eliminates_region() {
814        // Only x <= 1 is feasible; optimum is at x = 1.
815        let con = make_constraint(1.0);
816        let mut cbo = ConstrainedBo::new(
817            vec![(0.0_f64, 4.0_f64)],
818            vec![con],
819            ConstrainedBoConfig {
820                n_initial: 4,
821                seed: Some(99),
822                acq_n_candidates: 100,
823                ..Default::default()
824            },
825        )
826        .expect("create");
827        let result = cbo
828            .optimize(|x: &[f64]| (x[0] - 3.0_f64).powi(2), 12)
829            .expect("opt");
830
831        // Best feasible point should have x[0] <= 1.0.
832        if let Some(x_best) = &result.x_best {
833            assert!(
834                x_best[0] <= 1.0 + 1e-6,
835                "best feasible x={:.4} should be <= 1.0",
836                x_best[0]
837            );
838        }
839    }
840
841    #[test]
842    fn test_probability_of_feasibility_bounds() {
843        // PoF should be in [0, 1] before any data is available.
844        let gp = GpSurrogate::new(
845            Box::new(RbfKernel::default()),
846            GpSurrogateConfig {
847                noise_variance: 1e-4,
848                optimize_hyperparams: false,
849                ..Default::default()
850            },
851        );
852        let x = scirs2_core::ndarray::array![1.5];
853        let pof = ProbabilityOfFeasibility::evaluate(&x.view(), &gp).expect("pof");
854        assert!(
855            pof >= 0.0 && pof <= 1.0,
856            "PoF should be in [0,1], got {}",
857            pof
858        );
859    }
860
861    #[test]
862    fn test_multiple_constraints() {
863        // x[0] >= 0.5 and x[0] <= 2.5.
864        let c1 = BlackBoxConstraint {
865            name: "lower".into(),
866            evaluate: Box::new(|x: &[f64]| 0.5 - x[0]),
867        };
868        let c2 = BlackBoxConstraint {
869            name: "upper".into(),
870            evaluate: Box::new(|x: &[f64]| x[0] - 2.5),
871        };
872        let mut cbo = ConstrainedBo::new(
873            vec![(0.0_f64, 4.0_f64)],
874            vec![c1, c2],
875            ConstrainedBoConfig {
876                n_initial: 4,
877                seed: Some(7),
878                ..Default::default()
879            },
880        )
881        .expect("create");
882        let result = cbo
883            .optimize(|x: &[f64]| (x[0] - 1.5_f64).powi(2), 12)
884            .expect("opt");
885        // Some feasible points should have been found.
886        // (not guaranteed in 12 evals, but with seed=7 it should work)
887        assert!(
888            result.n_feasible > 0 || result.f_best.is_infinite(),
889            "expect some feasible points"
890        );
891    }
892
893    #[test]
894    fn test_pof_strategy() {
895        let con = make_constraint(2.0);
896        let mut cbo = ConstrainedBo::new(
897            vec![(0.0_f64, 4.0_f64)],
898            vec![con],
899            ConstrainedBoConfig {
900                strategy: ConstrainedAcquisitionStrategy::ProbabilityOfFeasibility,
901                n_initial: 3,
902                seed: Some(5),
903                ..Default::default()
904            },
905        )
906        .expect("create");
907        let result = cbo
908            .optimize(|x: &[f64]| (x[0] - 1.0_f64).powi(2), 8)
909            .expect("opt");
910        assert_eq!(result.n_evals, 8);
911    }
912
913    #[test]
914    fn test_augmented_lagrangian_strategy() {
915        let con = make_constraint(1.5);
916        let mut cbo = ConstrainedBo::new(
917            vec![(0.0_f64, 4.0_f64)],
918            vec![con],
919            ConstrainedBoConfig {
920                strategy: ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty: 1.0 },
921                n_initial: 3,
922                seed: Some(3),
923                ..Default::default()
924            },
925        )
926        .expect("create");
927        let result = cbo
928            .optimize(|x: &[f64]| (x[0] - 1.0_f64).powi(2), 8)
929            .expect("opt");
930        assert_eq!(result.n_evals, 8);
931    }
932
933    #[test]
934    fn test_constrained_optimize_fn() {
935        let con = BlackBoxConstraint {
936            name: "feasible".into(),
937            evaluate: Box::new(|x: &[f64]| x[0] - 3.0),
938        };
939        let result = constrained_optimize(
940            |x: &[f64]| (x[0] - 2.0_f64).powi(2),
941            vec![con],
942            vec![(0.0_f64, 4.0_f64)],
943            10,
944            Some(42),
945        )
946        .expect("opt");
947        assert!(result.n_evals > 0);
948    }
949
950    #[test]
951    fn test_expected_feasible_improvement() {
952        // Build a fitted GP for objective.
953        use scirs2_core::ndarray::{array, Array2};
954        let x = Array2::from_shape_vec((4, 1), vec![0.0, 1.0, 2.0, 3.0]).expect("shape");
955        let y = array![4.0, 1.0, 0.0, 1.0];
956        let mut gp = GpSurrogate::new(
957            Box::new(RbfKernel::default()),
958            GpSurrogateConfig {
959                noise_variance: 1e-4,
960                optimize_hyperparams: false,
961                ..Default::default()
962            },
963        );
964        gp.fit(&x, &y).expect("fit");
965
966        let efi = ExpectedFeasibleImprovement::new(0.0, 0.01);
967        let xq = array![1.5];
968        let val = efi.evaluate(&xq.view(), &gp, &[]).expect("eval");
969        assert!(val.is_finite(), "EFI should be finite, got {}", val);
970        assert!(val >= 0.0, "EFI should be non-negative, got {}", val);
971    }
972}