Skip to main content

scirs2_optimize/global/
multistart.rs

1//! Advanced Multi-Start Methods for Global Optimization
2//!
3//! This module provides advanced multi-start strategies that go beyond simple
4//! random restarts, including basin-hopping variants, stochastic tunneling,
5//! and deflation methods for finding multiple optima.
6//!
7//! ## Algorithms
8//!
9//! - **Multi-Start Local Search**: Parallel local optimization from diverse starting points
10//! - **Monotonic Basin-Hopping**: Basin-hopping with monotonic acceptance (always move downhill)
11//! - **Stochastic Tunneling**: Transforms the objective to flatten barriers between basins
12//! - **Deflation Methods**: Systematically finds multiple distinct optima
13//!
14//! ## References
15//!
16//! - Wales, D.J. & Doye, J.P.K. (1997). Global Optimization by Basin-Hopping
17//! - Wenzel, W. & Hamacher, K. (1999). Stochastic Tunneling Approach for Global Minimization
18//! - Brown, C.T. & Liebovitch, L.S. (2010). Fractal Analysis
19
20use crate::error::{OptimizeError, OptimizeResult};
21use crate::unconstrained::{
22    minimize, Bounds as UnconstrainedBounds, Method as UnconstrainedMethod,
23    OptimizeResult as LocalOptResult, Options,
24};
25use scirs2_core::ndarray::{Array1, ArrayView1};
26use scirs2_core::random::rngs::StdRng;
27use scirs2_core::random::{Rng, RngExt, SeedableRng};
28
29/// Bounds type for multi-start methods
30pub type Bounds = Vec<(f64, f64)>;
31
32// =====================================================================
33// Multi-Start Local Search
34// =====================================================================
35
36/// Options for advanced multi-start local search
37#[derive(Debug, Clone)]
38pub struct AdvancedMultiStartOptions {
39    /// Number of starting points
40    pub n_starts: usize,
41    /// Local optimization method
42    pub local_method: UnconstrainedMethod,
43    /// Maximum function evaluations per local optimization
44    pub max_local_fevals: usize,
45    /// Merge tolerance: distinct optima within this distance are merged
46    pub merge_tol: f64,
47    /// Random seed
48    pub seed: Option<u64>,
49}
50
51impl Default for AdvancedMultiStartOptions {
52    fn default() -> Self {
53        Self {
54            n_starts: 20,
55            local_method: UnconstrainedMethod::BFGS,
56            max_local_fevals: 5_000,
57            merge_tol: 1e-4,
58            seed: None,
59        }
60    }
61}
62
63/// Result of advanced multi-start optimization
64#[derive(Debug, Clone)]
65pub struct AdvancedMultiStartResult {
66    /// Best solution found
67    pub x: Array1<f64>,
68    /// Best function value
69    pub fun: f64,
70    /// All distinct local optima found (sorted by function value)
71    pub local_optima: Vec<(Array1<f64>, f64)>,
72    /// Total number of function evaluations
73    pub nfev: usize,
74    /// Number of successful local optimizations
75    pub n_successful: usize,
76    /// Whether optimization was successful
77    pub success: bool,
78    /// Termination message
79    pub message: String,
80}
81
82/// Run advanced multi-start local search
83pub fn advanced_multi_start<F>(
84    func: F,
85    bounds: &Bounds,
86    options: Option<AdvancedMultiStartOptions>,
87) -> OptimizeResult<AdvancedMultiStartResult>
88where
89    F: Fn(&ArrayView1<f64>) -> f64 + Clone + Send + Sync,
90{
91    let options = options.unwrap_or_default();
92    let ndim = bounds.len();
93    if ndim == 0 {
94        return Err(OptimizeError::InvalidInput(
95            "Bounds must have at least one dimension".to_string(),
96        ));
97    }
98
99    let seed = options
100        .seed
101        .unwrap_or_else(|| scirs2_core::random::rng().random_range(0..u64::MAX));
102    let mut rng = StdRng::seed_from_u64(seed);
103
104    // Generate Latin hypercube starting points
105    let starting_points = generate_lhs_points(ndim, options.n_starts, bounds, &mut rng);
106
107    let unconstrained_bounds = UnconstrainedBounds::from_vecs(
108        bounds.iter().map(|&(lb, _)| Some(lb)).collect(),
109        bounds.iter().map(|&(_, ub)| Some(ub)).collect(),
110    )
111    .ok();
112
113    let mut all_results: Vec<(Array1<f64>, f64)> = Vec::new();
114    let mut total_fevals = 0_usize;
115    let mut n_successful = 0_usize;
116
117    for x0 in starting_points {
118        let f = func.clone();
119        let opts = Options {
120            bounds: unconstrained_bounds.clone(),
121            max_iter: options.max_local_fevals,
122            ..Default::default()
123        };
124
125        let result = minimize(
126            move |x: &ArrayView1<f64>| f(x),
127            &x0.to_vec(),
128            options.local_method,
129            Some(opts),
130        );
131
132        match result {
133            Ok(res) => {
134                total_fevals += res.nfev;
135                if res.success {
136                    n_successful += 1;
137                    all_results.push((res.x, res.fun));
138                }
139            }
140            Err(_) => {
141                // Skip failed optimizations
142            }
143        }
144    }
145
146    // Merge nearby optima
147    let merged = merge_optima(&all_results, options.merge_tol);
148
149    // Sort by function value
150    let mut sorted = merged;
151    sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
152
153    if sorted.is_empty() {
154        return Ok(AdvancedMultiStartResult {
155            x: Array1::zeros(ndim),
156            fun: f64::INFINITY,
157            local_optima: Vec::new(),
158            nfev: total_fevals,
159            n_successful: 0,
160            success: false,
161            message: "All local optimizations failed".to_string(),
162        });
163    }
164
165    let best = sorted[0].clone();
166
167    Ok(AdvancedMultiStartResult {
168        x: best.0,
169        fun: best.1,
170        local_optima: sorted,
171        nfev: total_fevals,
172        n_successful,
173        success: true,
174        message: format!(
175            "Found {} distinct optima from {} starts",
176            n_successful, options.n_starts
177        ),
178    })
179}
180
181// =====================================================================
182// Monotonic Basin-Hopping
183// =====================================================================
184
185/// Options for monotonic basin-hopping
186#[derive(Debug, Clone)]
187pub struct MonotonicBasinHoppingOptions {
188    /// Number of basin-hopping steps
189    pub n_iterations: usize,
190    /// Step size for perturbation
191    pub step_size: f64,
192    /// Local optimization method
193    pub local_method: UnconstrainedMethod,
194    /// Random seed
195    pub seed: Option<u64>,
196    /// Step size adaptation: increase factor when accepted
197    pub step_increase: f64,
198    /// Step size adaptation: decrease factor when rejected
199    pub step_decrease: f64,
200    /// Minimum step size
201    pub step_min: f64,
202    /// Maximum step size
203    pub step_max: f64,
204    /// Target acceptance ratio for adaptive step size
205    pub target_accept_ratio: f64,
206}
207
208impl Default for MonotonicBasinHoppingOptions {
209    fn default() -> Self {
210        Self {
211            n_iterations: 100,
212            step_size: 0.5,
213            local_method: UnconstrainedMethod::BFGS,
214            seed: None,
215            step_increase: 1.1,
216            step_decrease: 0.9,
217            step_min: 1e-6,
218            step_max: 10.0,
219            target_accept_ratio: 0.5,
220        }
221    }
222}
223
224/// Result of monotonic basin-hopping
225#[derive(Debug, Clone)]
226pub struct MonotonicBasinHoppingResult {
227    /// Best solution found
228    pub x: Array1<f64>,
229    /// Best function value
230    pub fun: f64,
231    /// Total function evaluations
232    pub nfev: usize,
233    /// Number of accepted steps
234    pub n_accepted: usize,
235    /// Total iterations
236    pub nit: usize,
237    /// Final step size
238    pub final_step_size: f64,
239    /// Whether optimization was successful
240    pub success: bool,
241    /// Message
242    pub message: String,
243}
244
245/// Run monotonic basin-hopping
246///
247/// Unlike standard basin-hopping, monotonic basin-hopping only accepts
248/// moves that strictly decrease the function value. This converges faster
249/// to the nearest deep basin.
250pub fn monotonic_basin_hopping<F>(
251    func: F,
252    x0: &[f64],
253    bounds: &Bounds,
254    options: Option<MonotonicBasinHoppingOptions>,
255) -> OptimizeResult<MonotonicBasinHoppingResult>
256where
257    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
258{
259    let options = options.unwrap_or_default();
260    let ndim = x0.len();
261
262    let seed = options
263        .seed
264        .unwrap_or_else(|| scirs2_core::random::rng().random_range(0..u64::MAX));
265    let mut rng = StdRng::seed_from_u64(seed);
266
267    // Initial local minimization
268    let unconstrained_bounds = UnconstrainedBounds::from_vecs(
269        bounds.iter().map(|&(lb, _)| Some(lb)).collect(),
270        bounds.iter().map(|&(_, ub)| Some(ub)).collect(),
271    )
272    .ok();
273
274    let initial_opts = Options {
275        bounds: unconstrained_bounds.clone(),
276        ..Default::default()
277    };
278
279    let initial_result = minimize(func.clone(), x0, options.local_method, Some(initial_opts))
280        .map_err(|e| {
281            OptimizeError::ComputationError(format!("Initial minimization failed: {}", e))
282        })?;
283
284    let mut current_x = initial_result.x;
285    let mut current_f = initial_result.fun;
286    let mut best_x = current_x.clone();
287    let mut best_f = current_f;
288    let mut total_fevals = initial_result.nfev;
289    let mut step_size = options.step_size;
290    let mut n_accepted = 0_usize;
291
292    for iteration in 0..options.n_iterations {
293        // Perturb current point
294        let mut x_new = current_x.clone();
295        for i in 0..ndim {
296            x_new[i] += rng.random_range(-step_size..step_size);
297            // Enforce bounds
298            if i < bounds.len() {
299                x_new[i] = x_new[i].clamp(bounds[i].0, bounds[i].1);
300            }
301        }
302
303        // Local minimization from perturbed point
304        let local_opts = Options {
305            bounds: unconstrained_bounds.clone(),
306            ..Default::default()
307        };
308
309        let result = minimize(
310            func.clone(),
311            &x_new.to_vec(),
312            options.local_method,
313            Some(local_opts),
314        );
315
316        if let Ok(res) = result {
317            total_fevals += res.nfev;
318
319            // Monotonic acceptance: only accept if strictly better
320            if res.fun < current_f {
321                current_x = res.x;
322                current_f = res.fun;
323                n_accepted += 1;
324
325                if current_f < best_f {
326                    best_f = current_f;
327                    best_x = current_x.clone();
328                }
329
330                // Increase step size (encouraging exploration)
331                step_size = (step_size * options.step_increase).min(options.step_max);
332            } else {
333                // Decrease step size (focus on local area)
334                step_size = (step_size * options.step_decrease).max(options.step_min);
335            }
336        }
337
338        // Adaptive step size based on acceptance ratio
339        let accept_ratio = if iteration > 0 {
340            n_accepted as f64 / (iteration + 1) as f64
341        } else {
342            0.5
343        };
344
345        if accept_ratio < options.target_accept_ratio * 0.5 {
346            step_size = (step_size * 0.8).max(options.step_min);
347        } else if accept_ratio > options.target_accept_ratio * 1.5 {
348            step_size = (step_size * 1.2).min(options.step_max);
349        }
350    }
351
352    Ok(MonotonicBasinHoppingResult {
353        x: best_x,
354        fun: best_f,
355        nfev: total_fevals,
356        n_accepted,
357        nit: options.n_iterations,
358        final_step_size: step_size,
359        success: true,
360        message: format!(
361            "Monotonic basin-hopping: {} accepted of {} iterations",
362            n_accepted, options.n_iterations
363        ),
364    })
365}
366
367// =====================================================================
368// Stochastic Tunneling
369// =====================================================================
370
371/// Options for stochastic tunneling
372#[derive(Debug, Clone)]
373pub struct StochasticTunnelingOptions {
374    /// Number of iterations
375    pub n_iterations: usize,
376    /// Temperature parameter (controls tunneling probability)
377    pub gamma: f64,
378    /// Step size for random walk
379    pub step_size: f64,
380    /// Local optimization method (used periodically)
381    pub local_method: UnconstrainedMethod,
382    /// How often to run local optimization (every N iterations)
383    pub local_every: usize,
384    /// Random seed
385    pub seed: Option<u64>,
386}
387
388impl Default for StochasticTunnelingOptions {
389    fn default() -> Self {
390        Self {
391            n_iterations: 1_000,
392            gamma: 1.0,
393            step_size: 0.1,
394            local_method: UnconstrainedMethod::BFGS,
395            local_every: 50,
396            seed: None,
397        }
398    }
399}
400
401/// Result of stochastic tunneling
402#[derive(Debug, Clone)]
403pub struct StochasticTunnelingResult {
404    /// Best solution found
405    pub x: Array1<f64>,
406    /// Best function value
407    pub fun: f64,
408    /// Total function evaluations
409    pub nfev: usize,
410    /// Number of iterations
411    pub nit: usize,
412    /// Number of local optimizations performed
413    pub n_local_opts: usize,
414    /// Whether successful
415    pub success: bool,
416    /// Message
417    pub message: String,
418}
419
420/// Run stochastic tunneling optimization
421///
422/// Stochastic tunneling transforms the objective function to:
423///   STUN(x) = 1 - exp(-gamma * (f(x) - f_ref))
424///
425/// This flattens barriers between basins while preserving the global minimum,
426/// allowing the random walk to "tunnel" through barriers.
427pub fn stochastic_tunneling<F>(
428    func: F,
429    x0: &[f64],
430    bounds: &Bounds,
431    options: Option<StochasticTunnelingOptions>,
432) -> OptimizeResult<StochasticTunnelingResult>
433where
434    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
435{
436    let options = options.unwrap_or_default();
437    let ndim = x0.len();
438
439    let seed = options
440        .seed
441        .unwrap_or_else(|| scirs2_core::random::rng().random_range(0..u64::MAX));
442    let mut rng = StdRng::seed_from_u64(seed);
443
444    let mut current_x = Array1::from_vec(x0.to_vec());
445    // Enforce bounds on initial point
446    for i in 0..ndim.min(bounds.len()) {
447        current_x[i] = current_x[i].clamp(bounds[i].0, bounds[i].1);
448    }
449
450    let mut current_f = func(&current_x.view());
451    let mut best_x = current_x.clone();
452    let mut best_f = current_f;
453    let mut f_ref = current_f; // Reference value for STUN transformation
454    let mut fevals = 1_usize;
455    let mut n_local_opts = 0_usize;
456
457    let unconstrained_bounds = UnconstrainedBounds::from_vecs(
458        bounds.iter().map(|&(lb, _)| Some(lb)).collect(),
459        bounds.iter().map(|&(_, ub)| Some(ub)).collect(),
460    )
461    .ok();
462
463    for iteration in 0..options.n_iterations {
464        // STUN transformation
465        let stun_current = 1.0 - (-options.gamma * (current_f - f_ref)).exp();
466
467        // Propose new point
468        let mut x_new = current_x.clone();
469        for i in 0..ndim {
470            x_new[i] += rng.random_range(-options.step_size..options.step_size);
471            if i < bounds.len() {
472                x_new[i] = x_new[i].clamp(bounds[i].0, bounds[i].1);
473            }
474        }
475
476        let f_new = func(&x_new.view());
477        fevals += 1;
478
479        let stun_new = 1.0 - (-options.gamma * (f_new - f_ref)).exp();
480
481        // Metropolis acceptance on the STUN-transformed landscape
482        let delta_stun = stun_new - stun_current;
483        let accept = if delta_stun <= 0.0 {
484            true
485        } else {
486            let prob = (-delta_stun).exp();
487            rng.random_range(0.0..1.0) < prob
488        };
489
490        if accept {
491            current_x = x_new;
492            current_f = f_new;
493
494            if current_f < best_f {
495                best_f = current_f;
496                best_x = current_x.clone();
497                f_ref = best_f; // Update reference to best found
498            }
499        }
500
501        // Periodically run local optimization
502        if (iteration + 1) % options.local_every == 0 {
503            let local_opts = Options {
504                bounds: unconstrained_bounds.clone(),
505                ..Default::default()
506            };
507
508            if let Ok(res) = minimize(
509                func.clone(),
510                &current_x.to_vec(),
511                options.local_method,
512                Some(local_opts),
513            ) {
514                fevals += res.nfev;
515                n_local_opts += 1;
516
517                if res.fun < best_f {
518                    best_f = res.fun;
519                    best_x = res.x.clone();
520                    f_ref = best_f;
521                }
522                if res.success {
523                    current_x = res.x;
524                    current_f = res.fun;
525                }
526            }
527        }
528    }
529
530    Ok(StochasticTunnelingResult {
531        x: best_x,
532        fun: best_f,
533        nfev: fevals,
534        nit: options.n_iterations,
535        n_local_opts,
536        success: true,
537        message: format!(
538            "Stochastic tunneling: {} iterations, {} local optimizations",
539            options.n_iterations, n_local_opts
540        ),
541    })
542}
543
544// =====================================================================
545// Deflation Methods
546// =====================================================================
547
548/// Options for deflation-based multi-optima search
549#[derive(Debug, Clone)]
550pub struct DeflationOptions {
551    /// Maximum number of optima to find
552    pub max_optima: usize,
553    /// Deflation radius: penalty is applied within this radius of known optima
554    pub deflation_radius: f64,
555    /// Deflation exponent (higher = sharper penalty)
556    pub deflation_power: f64,
557    /// Number of random starts for each deflated search
558    pub n_starts: usize,
559    /// Local optimization method
560    pub local_method: UnconstrainedMethod,
561    /// Function value threshold: stop if all found optima have f > threshold
562    pub f_threshold: f64,
563    /// Random seed
564    pub seed: Option<u64>,
565}
566
567impl Default for DeflationOptions {
568    fn default() -> Self {
569        Self {
570            max_optima: 10,
571            deflation_radius: 0.1,
572            deflation_power: 2.0,
573            n_starts: 10,
574            local_method: UnconstrainedMethod::BFGS,
575            f_threshold: f64::INFINITY,
576            seed: None,
577        }
578    }
579}
580
581/// Result of deflation-based search
582#[derive(Debug, Clone)]
583pub struct DeflationResult {
584    /// All distinct optima found, sorted by function value
585    pub optima: Vec<(Array1<f64>, f64)>,
586    /// Total function evaluations
587    pub nfev: usize,
588    /// Whether successful (found at least one optimum)
589    pub success: bool,
590    /// Message
591    pub message: String,
592}
593
594/// Find multiple optima using deflation
595///
596/// The deflation method works by:
597/// 1. Finding a local/global optimum
598/// 2. Applying a "deflation" transformation that repels solutions away from known optima
599/// 3. Searching the deflated landscape to find the next optimum
600/// 4. Repeating until the desired number of optima are found
601pub fn deflation_search<F>(
602    func: F,
603    bounds: &Bounds,
604    options: Option<DeflationOptions>,
605) -> OptimizeResult<DeflationResult>
606where
607    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
608{
609    let options = options.unwrap_or_default();
610    let ndim = bounds.len();
611    if ndim == 0 {
612        return Err(OptimizeError::InvalidInput(
613            "Bounds must have at least one dimension".to_string(),
614        ));
615    }
616
617    let seed = options
618        .seed
619        .unwrap_or_else(|| scirs2_core::random::rng().random_range(0..u64::MAX));
620    let mut rng = StdRng::seed_from_u64(seed);
621
622    let mut found_optima: Vec<(Array1<f64>, f64)> = Vec::new();
623    let mut total_fevals = 0_usize;
624
625    let unconstrained_bounds = UnconstrainedBounds::from_vecs(
626        bounds.iter().map(|&(lb, _)| Some(lb)).collect(),
627        bounds.iter().map(|&(_, ub)| Some(ub)).collect(),
628    )
629    .ok();
630
631    for search_round in 0..options.max_optima {
632        let mut best_result: Option<LocalOptResult<f64>> = None;
633        let known = found_optima.clone();
634        let deflation_radius = options.deflation_radius;
635        let deflation_power = options.deflation_power;
636
637        // Create deflated objective
638        let deflated_func = {
639            let f = func.clone();
640            let known_optima = known.clone();
641            move |x: &ArrayView1<f64>| -> f64 {
642                let f_val = f(x);
643
644                // Compute deflation factor
645                let mut deflation = 1.0;
646                for (opt_x, _) in &known_optima {
647                    let mut sq_dist = 0.0;
648                    for i in 0..x.len() {
649                        let diff = x[i] - opt_x[i];
650                        sq_dist += diff * diff;
651                    }
652                    let dist = sq_dist.sqrt();
653                    if dist < deflation_radius {
654                        // Polynomial deflation: multiply by (dist/radius)^power
655                        let ratio = dist / deflation_radius;
656                        deflation *= ratio.powf(deflation_power);
657                    }
658                }
659
660                if deflation < 1e-30 {
661                    f64::MAX / 2.0 // Very high value near known optima
662                } else {
663                    f_val / deflation // Inflate function value near known optima
664                }
665            }
666        };
667
668        // Multi-start search on deflated landscape
669        for _ in 0..options.n_starts {
670            let mut x0 = vec![0.0; ndim];
671            for i in 0..ndim {
672                x0[i] = rng.random_range(bounds[i].0..bounds[i].1);
673            }
674
675            let local_opts = Options {
676                bounds: unconstrained_bounds.clone(),
677                ..Default::default()
678            };
679
680            let df = deflated_func.clone();
681            if let Ok(res) = minimize(
682                move |x: &ArrayView1<f64>| df(x),
683                &x0,
684                options.local_method,
685                Some(local_opts),
686            ) {
687                total_fevals += res.nfev;
688
689                // Evaluate original function at result
690                let f_original = func(&res.x.view());
691                total_fevals += 1;
692
693                let is_new = !found_optima.iter().any(|(opt_x, _)| {
694                    let mut sq_dist = 0.0;
695                    for i in 0..ndim {
696                        let diff = res.x[i] - opt_x[i];
697                        sq_dist += diff * diff;
698                    }
699                    sq_dist.sqrt() < options.deflation_radius
700                });
701
702                if is_new && f_original < options.f_threshold {
703                    let update = match &best_result {
704                        None => true,
705                        Some(prev) => f_original < prev.fun,
706                    };
707                    if update {
708                        best_result = Some(LocalOptResult {
709                            x: res.x,
710                            fun: f_original,
711                            success: true,
712                            message: format!("Deflation round {}", search_round),
713                            ..Default::default()
714                        });
715                    }
716                }
717            }
718        }
719
720        match best_result {
721            Some(res) => {
722                found_optima.push((res.x, res.fun));
723            }
724            None => {
725                // No more optima found
726                break;
727            }
728        }
729    }
730
731    // Sort by function value
732    found_optima.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
733
734    let success = !found_optima.is_empty();
735    let message = format!("Found {} distinct optima", found_optima.len());
736
737    Ok(DeflationResult {
738        optima: found_optima,
739        nfev: total_fevals,
740        success,
741        message,
742    })
743}
744
745// =====================================================================
746// Utility functions
747// =====================================================================
748
749/// Generate Latin Hypercube Sampling points
750fn generate_lhs_points(
751    ndim: usize,
752    n: usize,
753    bounds: &Bounds,
754    rng: &mut StdRng,
755) -> Vec<Array1<f64>> {
756    let mut points = Vec::with_capacity(n);
757
758    // Create permutation for each dimension
759    let mut perms: Vec<Vec<usize>> = (0..ndim)
760        .map(|_| {
761            let mut perm: Vec<usize> = (0..n).collect();
762            // Fisher-Yates shuffle
763            for i in (1..n).rev() {
764                let j = rng.random_range(0..=i);
765                perm.swap(i, j);
766            }
767            perm
768        })
769        .collect();
770
771    for i in 0..n {
772        let mut point = Array1::zeros(ndim);
773        for j in 0..ndim {
774            let cell = perms[j][i];
775            let u = rng.random_range(0.0..1.0);
776            let t = (cell as f64 + u) / n as f64;
777            let (lb, ub) = bounds[j];
778            point[j] = lb + t * (ub - lb);
779        }
780        points.push(point);
781    }
782
783    let _ = perms; // suppress unused warning
784    points
785}
786
787/// Merge nearby optima
788fn merge_optima(optima: &[(Array1<f64>, f64)], tol: f64) -> Vec<(Array1<f64>, f64)> {
789    let mut merged: Vec<(Array1<f64>, f64)> = Vec::new();
790
791    for (x, f) in optima {
792        let mut is_duplicate = false;
793        for (mx, mf) in &mut merged {
794            let mut sq_dist = 0.0;
795            for i in 0..x.len() {
796                let diff = x[i] - mx[i];
797                sq_dist += diff * diff;
798            }
799            if sq_dist.sqrt() < tol {
800                // Keep the better one
801                if *f < *mf {
802                    *mx = x.clone();
803                    *mf = *f;
804                }
805                is_duplicate = true;
806                break;
807            }
808        }
809        if !is_duplicate {
810            merged.push((x.clone(), *f));
811        }
812    }
813
814    merged
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    fn sphere(x: &ArrayView1<f64>) -> f64 {
822        x.iter().map(|xi| xi * xi).sum()
823    }
824
825    fn rosenbrock(x: &ArrayView1<f64>) -> f64 {
826        let mut sum = 0.0;
827        for i in 0..x.len() - 1 {
828            sum += 100.0 * (x[i + 1] - x[i] * x[i]).powi(2) + (1.0 - x[i]).powi(2);
829        }
830        sum
831    }
832
833    fn rastrigin(x: &ArrayView1<f64>) -> f64 {
834        let n = x.len() as f64;
835        let mut sum = 10.0 * n;
836        for &xi in x.iter() {
837            sum += xi * xi - 10.0 * (2.0 * std::f64::consts::PI * xi).cos();
838        }
839        sum
840    }
841
842    /// Function with two minima
843    fn two_minima(x: &ArrayView1<f64>) -> f64 {
844        let x0 = x[0];
845        // Two wells at x=-2 (deeper) and x=2 (shallower)
846        let well1 = (x0 + 2.0).powi(2);
847        let well2 = 0.5 * (x0 - 2.0).powi(2) + 0.5;
848        well1.min(well2)
849    }
850
851    #[test]
852    fn test_advanced_multi_start_sphere() {
853        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
854        let result = advanced_multi_start(
855            sphere,
856            &bounds,
857            Some(AdvancedMultiStartOptions {
858                n_starts: 10,
859                seed: Some(42),
860                ..Default::default()
861            }),
862        );
863        assert!(result.is_ok());
864        let res = result.expect("Multi-start sphere failed");
865        assert!(res.fun < 0.1, "Multi-start sphere value: {}", res.fun);
866        assert!(res.n_successful > 0);
867    }
868
869    #[test]
870    fn test_advanced_multi_start_rosenbrock() {
871        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
872        let result = advanced_multi_start(
873            rosenbrock,
874            &bounds,
875            Some(AdvancedMultiStartOptions {
876                n_starts: 15,
877                seed: Some(123),
878                ..Default::default()
879            }),
880        );
881        assert!(result.is_ok());
882        let res = result.expect("Multi-start Rosenbrock failed");
883        assert!(res.fun < 1.0, "Multi-start Rosenbrock: {}", res.fun);
884    }
885
886    #[test]
887    fn test_monotonic_basin_hopping_sphere() {
888        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
889        let result = monotonic_basin_hopping(
890            sphere,
891            &[3.0, -2.0],
892            &bounds,
893            Some(MonotonicBasinHoppingOptions {
894                n_iterations: 30,
895                seed: Some(42),
896                ..Default::default()
897            }),
898        );
899        assert!(result.is_ok());
900        let res = result.expect("Monotonic BH sphere failed");
901        assert!(res.fun < 0.1, "Monotonic BH sphere: {}", res.fun);
902    }
903
904    #[test]
905    fn test_monotonic_basin_hopping_rastrigin() {
906        let bounds = vec![(-5.12, 5.12), (-5.12, 5.12)];
907        let result = monotonic_basin_hopping(
908            rastrigin,
909            &[2.0, -3.0],
910            &bounds,
911            Some(MonotonicBasinHoppingOptions {
912                n_iterations: 50,
913                step_size: 1.0,
914                seed: Some(99),
915                ..Default::default()
916            }),
917        );
918        assert!(result.is_ok());
919        let res = result.expect("Monotonic BH Rastrigin failed");
920        assert!(res.fun < 20.0, "Monotonic BH Rastrigin: {}", res.fun);
921    }
922
923    #[test]
924    fn test_stochastic_tunneling_sphere() {
925        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
926        let result = stochastic_tunneling(
927            sphere,
928            &[3.0, -2.0],
929            &bounds,
930            Some(StochasticTunnelingOptions {
931                n_iterations: 200,
932                gamma: 1.0,
933                step_size: 0.5,
934                local_every: 50,
935                seed: Some(42),
936                ..Default::default()
937            }),
938        );
939        assert!(result.is_ok());
940        let res = result.expect("Stochastic tunneling sphere failed");
941        assert!(res.fun < 1.0, "Stochastic tunneling sphere: {}", res.fun);
942    }
943
944    #[test]
945    fn test_stochastic_tunneling_rastrigin() {
946        let bounds = vec![(-5.12, 5.12), (-5.12, 5.12)];
947        let result = stochastic_tunneling(
948            rastrigin,
949            &[2.0, -3.0],
950            &bounds,
951            Some(StochasticTunnelingOptions {
952                n_iterations: 500,
953                gamma: 0.5,
954                step_size: 0.5,
955                local_every: 50,
956                seed: Some(42),
957                ..Default::default()
958            }),
959        );
960        assert!(result.is_ok());
961        let res = result.expect("Stochastic tunneling Rastrigin failed");
962        assert!(
963            res.fun < 20.0,
964            "Stochastic tunneling Rastrigin: {}",
965            res.fun
966        );
967    }
968
969    #[test]
970    fn test_deflation_two_minima() {
971        let bounds = vec![(-5.0, 5.0)];
972        let result = deflation_search(
973            two_minima,
974            &bounds,
975            Some(DeflationOptions {
976                max_optima: 3,
977                deflation_radius: 0.5,
978                n_starts: 10,
979                seed: Some(42),
980                ..Default::default()
981            }),
982        );
983        assert!(result.is_ok());
984        let res = result.expect("Deflation two_minima failed");
985        assert!(!res.optima.is_empty(), "Should find at least one optimum");
986    }
987
988    #[test]
989    fn test_deflation_sphere() {
990        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
991        let result = deflation_search(
992            sphere,
993            &bounds,
994            Some(DeflationOptions {
995                max_optima: 3,
996                deflation_radius: 1.0,
997                n_starts: 5,
998                seed: Some(42),
999                ..Default::default()
1000            }),
1001        );
1002        assert!(result.is_ok());
1003        let res = result.expect("Deflation sphere failed");
1004        assert!(res.success);
1005        // Sphere has only one global minimum, so we should find it
1006        assert!(
1007            res.optima[0].1 < 1.0,
1008            "Best optimum value: {}",
1009            res.optima[0].1
1010        );
1011    }
1012
1013    #[test]
1014    fn test_lhs_generation() {
1015        let bounds = vec![(-1.0, 1.0), (0.0, 10.0)];
1016        let mut rng = StdRng::seed_from_u64(42);
1017        let points = generate_lhs_points(2, 10, &bounds, &mut rng);
1018        assert_eq!(points.len(), 10);
1019        for p in &points {
1020            assert!(p[0] >= -1.0 && p[0] <= 1.0);
1021            assert!(p[1] >= 0.0 && p[1] <= 10.0);
1022        }
1023    }
1024
1025    #[test]
1026    fn test_merge_optima() {
1027        let optima = vec![
1028            (Array1::from_vec(vec![1.0, 1.0]), 0.5),
1029            (Array1::from_vec(vec![1.001, 1.001]), 0.4), // close to first
1030            (Array1::from_vec(vec![5.0, 5.0]), 1.0),     // far from first
1031        ];
1032        let merged = merge_optima(&optima, 0.01);
1033        assert_eq!(merged.len(), 2);
1034        // The better of the two close ones should be kept
1035        assert!((merged[0].1 - 0.4).abs() < 1e-10);
1036    }
1037
1038    #[test]
1039    fn test_advanced_multi_start_empty_bounds() {
1040        let result = advanced_multi_start(sphere, &vec![], None);
1041        assert!(result.is_err());
1042    }
1043
1044    #[test]
1045    fn test_monotonic_bh_adaptive_step() {
1046        let bounds = vec![(-5.0, 5.0)];
1047        let result = monotonic_basin_hopping(
1048            |x: &ArrayView1<f64>| x[0] * x[0],
1049            &[4.0],
1050            &bounds,
1051            Some(MonotonicBasinHoppingOptions {
1052                n_iterations: 20,
1053                step_size: 0.5,
1054                step_increase: 1.2,
1055                step_decrease: 0.8,
1056                seed: Some(42),
1057                ..Default::default()
1058            }),
1059        );
1060        assert!(result.is_ok());
1061        let res = result.expect("Adaptive step MBH failed");
1062        assert!(res.fun < 1.0, "Adaptive step MBH value: {}", res.fun);
1063    }
1064}