1use 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
60fn 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
86pub struct BlackBoxConstraint {
94 pub name: String,
96 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#[derive(Debug, Clone, Copy, PartialEq)]
112pub enum ConstrainedAcquisitionStrategy {
113 ExpectedFeasibleImprovement,
116 ProbabilityOfFeasibility,
119 ConstrainedExpectedImprovement,
121 AugmentedLagrangian {
124 penalty: f64,
126 },
127}
128
129impl Default for ConstrainedAcquisitionStrategy {
130 fn default() -> Self {
131 Self::ExpectedFeasibleImprovement
132 }
133}
134
135#[derive(Clone)]
141pub struct ConstrainedBoConfig {
142 pub strategy: ConstrainedAcquisitionStrategy,
144 pub n_initial: usize,
146 pub acq_n_candidates: usize,
148 pub xi: f64,
150 pub min_pof: f64,
152 pub seed: Option<u64>,
154 pub verbose: usize,
156 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#[derive(Debug, Clone)]
185pub struct ConstrainedObservation {
186 pub x: Array1<f64>,
188 pub y: f64,
190 pub constraint_values: Vec<f64>,
192 pub feasible: bool,
194}
195
196#[derive(Debug, Clone)]
202pub struct ConstrainedBoResult {
203 pub x_best: Option<Array1<f64>>,
205 pub f_best: f64,
207 pub observations: Vec<ConstrainedObservation>,
209 pub n_evals: usize,
211 pub best_history: Vec<f64>,
213 pub n_feasible: usize,
215}
216
217struct ConstraintSurrogate {
224 gp: GpSurrogate,
226 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 fn fit(&mut self, x: &Array2<f64>, g: &Array1<f64>) -> OptimizeResult<()> {
238 if x.nrows() < 2 {
239 return Ok(()); }
241 self.gp.fit(x, g)
242 }
243
244 fn predict_pof(&self, x: &scirs2_core::ndarray::ArrayView1<f64>) -> OptimizeResult<f64> {
248 if self.gp.n_train() == 0 {
249 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 Ok(norm_cdf(-mu / sigma))
260 }
261
262 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 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
284fn 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
302pub struct ConstrainedBo {
308 bounds: Vec<(f64, f64)>,
309 constraints: Vec<BlackBoxConstraint>,
311 config: ConstrainedBoConfig,
312 obj_surrogate: GpSurrogate,
314 constraint_surrogates: Vec<ConstraintSurrogate>,
316 observations: Vec<ConstrainedObservation>,
318 rng: StdRng,
319 f_best: f64,
320 best_history: Vec<f64>,
321 lagrange_multipliers: Vec<f64>,
323}
324
325impl ConstrainedBo {
326 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 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 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 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 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 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 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 pub fn ask(&mut self) -> OptimizeResult<Vec<f64>> {
449 let ndim = self.bounds.len();
450
451 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 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 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; Ok(best_x)
501 }
502
503 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 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 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 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); }
576 }
577 }
578
579 self.update_lagrange_multipliers();
581
582 Ok(())
583 }
584
585 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 pub fn observations(&self) -> &[ConstrainedObservation] {
644 &self.observations
645 }
646
647 pub fn n_feasible(&self) -> usize {
649 self.observations.iter().filter(|o| o.feasible).count()
650 }
651}
652
653pub struct ExpectedFeasibleImprovement {
663 pub f_best: f64,
665 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 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 let mut pof = 1.0_f64;
689 for cgp in constraint_gps {
690 if cgp.n_train() == 0 {
691 pof *= 0.5; 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
711pub struct ProbabilityOfFeasibility;
715
716impl ProbabilityOfFeasibility {
717 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 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
745pub 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#[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 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 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 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 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 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 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 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}