Skip to main content

scirs2_optimize/global/
direct.rs

1//! DIRECT (DIviding RECTangles) Global Optimization Algorithm
2//!
3//! DIRECT is a deterministic global optimization algorithm that does not require
4//! any knowledge of the Lipschitz constant. It works by systematically dividing
5//! the search space into hyperrectangles and evaluating the function at their
6//! centers, selecting "potentially optimal" rectangles for further division.
7//!
8//! ## Key properties
9//!
10//! - No gradient or Lipschitz constant required
11//! - Guaranteed convergence to global optimum (under mild conditions)
12//! - Balances local refinement and global exploration
13//! - Budget management via max evaluations and iterations
14//!
15//! ## References
16//!
17//! - Jones, D.R., Perttunen, C.D., Stuckman, B.E. (1993).
18//!   Lipschitzian Optimization Without the Lipschitz Constant.
19//!   Journal of Optimization Theory and Applications, 79(1), 157-181.
20//! - Gablonsky, J.M. & Kelley, C.T. (2001). A Locally-Biased form of
21//!   the DIRECT Algorithm. Journal of Global Optimization, 21, 27-37.
22
23use crate::error::{OptimizeError, OptimizeResult};
24use scirs2_core::ndarray::{Array1, ArrayView1};
25
26/// Options for the DIRECT algorithm
27#[derive(Debug, Clone)]
28pub struct DirectOptions {
29    /// Maximum number of function evaluations
30    pub max_fevals: usize,
31    /// Maximum number of iterations (subdivisions)
32    pub max_iterations: usize,
33    /// Minimum function value improvement to continue (absolute tolerance)
34    pub ftol_abs: f64,
35    /// Relative tolerance on function value
36    pub ftol_rel: f64,
37    /// Minimum rectangle size (stops when rectangles become too small)
38    pub vol_tol: f64,
39    /// Epsilon parameter for selecting potentially optimal rectangles.
40    /// Larger values bias toward global search; smaller toward local refinement.
41    /// Jones (1993) recommends 1e-4 as a good default.
42    pub epsilon: f64,
43    /// Whether to use the locally-biased variant (DIRECT-L, Gablonsky & Kelley 2001)
44    pub locally_biased: bool,
45}
46
47impl Default for DirectOptions {
48    fn default() -> Self {
49        Self {
50            max_fevals: 10_000,
51            max_iterations: 1_000,
52            ftol_abs: 1e-12,
53            ftol_rel: 1e-12,
54            vol_tol: 1e-16,
55            epsilon: 1e-4,
56            locally_biased: false,
57        }
58    }
59}
60
61/// Result of DIRECT optimization
62#[derive(Debug, Clone)]
63pub struct DirectResult {
64    /// Best solution found
65    pub x: Array1<f64>,
66    /// Best function value found
67    pub fun: f64,
68    /// Number of function evaluations used
69    pub nfev: usize,
70    /// Number of iterations (divisions)
71    pub nit: usize,
72    /// Number of rectangles at termination
73    pub n_rectangles: usize,
74    /// Whether optimization converged
75    pub success: bool,
76    /// Termination message
77    pub message: String,
78}
79
80/// A hyperrectangle in the DIRECT algorithm
81#[derive(Debug, Clone)]
82struct Rectangle {
83    /// Center point in normalized [0,1]^n coordinates
84    center: Vec<f64>,
85    /// Function value at the center
86    f_center: f64,
87    /// Half-widths of the rectangle in each dimension (in [0,1] units)
88    half_widths: Vec<f64>,
89    /// Measure of the rectangle's "size" (max half-width or diagonal)
90    size: f64,
91}
92
93impl Rectangle {
94    fn new(center: Vec<f64>, f_center: f64, half_widths: Vec<f64>) -> Self {
95        let size = half_widths.iter().copied().fold(0.0_f64, f64::max);
96        Self {
97            center,
98            f_center,
99            half_widths,
100            size,
101        }
102    }
103
104    /// Compute the diagonal distance (L2 norm of half-widths)
105    fn diagonal(&self) -> f64 {
106        self.half_widths.iter().map(|w| w * w).sum::<f64>().sqrt()
107    }
108
109    /// Volume of the rectangle (product of widths)
110    fn volume(&self) -> f64 {
111        self.half_widths.iter().map(|w| 2.0 * w).product::<f64>()
112    }
113}
114
115/// DIRECT optimizer
116pub struct Direct<F>
117where
118    F: Fn(&ArrayView1<f64>) -> f64,
119{
120    func: F,
121    lower_bounds: Vec<f64>,
122    upper_bounds: Vec<f64>,
123    options: DirectOptions,
124    ndim: usize,
125    /// All rectangles currently active
126    rectangles: Vec<Rectangle>,
127    /// Best function value found
128    best_f: f64,
129    /// Best point found (in original coordinates)
130    best_x: Vec<f64>,
131    /// Number of function evaluations
132    fevals: usize,
133}
134
135impl<F> Direct<F>
136where
137    F: Fn(&ArrayView1<f64>) -> f64,
138{
139    /// Create a new DIRECT optimizer
140    pub fn new(
141        func: F,
142        lower_bounds: Vec<f64>,
143        upper_bounds: Vec<f64>,
144        options: DirectOptions,
145    ) -> OptimizeResult<Self> {
146        let ndim = lower_bounds.len();
147        if ndim == 0 {
148            return Err(OptimizeError::InvalidInput(
149                "Dimension must be at least 1".to_string(),
150            ));
151        }
152        if upper_bounds.len() != ndim {
153            return Err(OptimizeError::InvalidInput(
154                "Lower and upper bounds must have the same length".to_string(),
155            ));
156        }
157        for i in 0..ndim {
158            if lower_bounds[i] >= upper_bounds[i] {
159                return Err(OptimizeError::InvalidInput(format!(
160                    "Lower bound must be less than upper bound for dimension {}: {} >= {}",
161                    i, lower_bounds[i], upper_bounds[i]
162                )));
163            }
164        }
165
166        Ok(Self {
167            func,
168            lower_bounds,
169            upper_bounds,
170            options,
171            ndim,
172            rectangles: Vec::new(),
173            best_f: f64::INFINITY,
174            best_x: vec![0.0; ndim],
175            fevals: 0,
176        })
177    }
178
179    /// Convert normalized [0,1]^n coordinates to original coordinates
180    fn to_original(&self, normalized: &[f64]) -> Vec<f64> {
181        normalized
182            .iter()
183            .enumerate()
184            .map(|(i, &x)| self.lower_bounds[i] + x * (self.upper_bounds[i] - self.lower_bounds[i]))
185            .collect()
186    }
187
188    /// Evaluate the function at a point (normalized coordinates)
189    fn evaluate(&mut self, normalized_point: &[f64]) -> f64 {
190        let original = self.to_original(normalized_point);
191        let arr = Array1::from_vec(original.clone());
192        let f_val = (self.func)(&arr.view());
193        self.fevals += 1;
194
195        if f_val < self.best_f {
196            self.best_f = f_val;
197            self.best_x = original;
198        }
199
200        f_val
201    }
202
203    /// Initialize the algorithm with the unit hypercube center
204    fn initialize(&mut self) {
205        let center = vec![0.5; self.ndim];
206        let f_center = self.evaluate(&center);
207        let half_widths = vec![0.5; self.ndim];
208        let rect = Rectangle::new(center, f_center, half_widths);
209        self.rectangles.push(rect);
210    }
211
212    /// Select potentially optimal rectangles
213    ///
214    /// A rectangle is potentially optimal if there exists some Lipschitz constant K > 0
215    /// such that the rectangle could contain the global minimum. This is determined
216    /// by checking if the rectangle lies on the lower-right convex hull in the
217    /// (size, f_center) space.
218    fn select_potentially_optimal(&self) -> Vec<usize> {
219        if self.rectangles.is_empty() {
220            return Vec::new();
221        }
222
223        let epsilon = self.options.epsilon;
224        let f_min = self.best_f;
225
226        // Group rectangles by their size (quantized to avoid floating point issues)
227        let mut size_groups: std::collections::BTreeMap<u64, Vec<usize>> =
228            std::collections::BTreeMap::new();
229        for (idx, rect) in self.rectangles.iter().enumerate() {
230            let size_key = (rect.diagonal() * 1e12) as u64;
231            size_groups.entry(size_key).or_default().push(idx);
232        }
233
234        // For each size group, find the rectangle with the lowest function value
235        let mut hull_candidates: Vec<(f64, f64, usize)> = Vec::new(); // (size, f_val, idx)
236        for (_size_key, indices) in &size_groups {
237            let mut best_idx = indices[0];
238            let mut best_f = self.rectangles[indices[0]].f_center;
239            for &idx in &indices[1..] {
240                if self.rectangles[idx].f_center < best_f {
241                    best_f = self.rectangles[idx].f_center;
242                    best_idx = idx;
243                }
244            }
245            hull_candidates.push((self.rectangles[best_idx].diagonal(), best_f, best_idx));
246        }
247
248        // Sort by size
249        hull_candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
250
251        if hull_candidates.is_empty() {
252            return Vec::new();
253        }
254
255        // Select potentially optimal rectangles using the correct Jones (1993) criterion.
256        //
257        // Rectangle i is potentially optimal if there EXISTS K >= 0 such that:
258        //   (1) f_i - K * d_i <= f_j - K * d_j  for all j != i   (dominance condition)
259        //   (2) f_i - K * d_i <= f_min - epsilon * |f_min|         (improvement condition)
260        //
261        // From (1) with d_j > d_i:  K <= (f_j - f_i) / (d_j - d_i)   → upper bound K_upper
262        // From (1) with d_j < d_i:  K >= (f_j - f_i) / (d_j - d_i)   → lower bound K_lower
263        //
264        // The feasible K range is [max(0, K_lower), K_upper].
265        // We use K = K_upper (tightest constraint from larger rectangles) to check (2).
266        // If K_upper < max(0, K_lower) there is no feasible K and the rectangle is not
267        // potentially optimal.
268        //
269        // For the locally-biased variant (DIRECT-L) we only check the immediate neighbor
270        // as the upper-bound constraint (Gablonsky & Kelley 2001).
271        let mut selected = Vec::new();
272
273        for i in 0..hull_candidates.len() {
274            let (d_i, f_i, idx) = hull_candidates[i];
275
276            // Compute K_lower: tightest lower bound from all smaller rectangles.
277            let mut k_lower = 0.0_f64; // must be non-negative
278            for k in 0..i {
279                let (d_k, f_k, _) = hull_candidates[k];
280                if d_k < d_i && (d_i - d_k).abs() > 1e-15 {
281                    let slope = (f_i - f_k) / (d_i - d_k);
282                    if slope > k_lower {
283                        k_lower = slope;
284                    }
285                }
286            }
287
288            // Compute K_upper: tightest upper bound from larger rectangles.
289            let k_upper = if self.options.locally_biased {
290                // DIRECT-L: only use the immediate next-larger neighbor
291                if i + 1 < hull_candidates.len() {
292                    let (d_next, f_next, _) = hull_candidates[i + 1];
293                    if (d_next - d_i).abs() > 1e-15 {
294                        (f_next - f_i) / (d_next - d_i)
295                    } else {
296                        f64::INFINITY
297                    }
298                } else {
299                    f64::INFINITY
300                }
301            } else {
302                // Standard DIRECT: minimum slope to any larger rectangle
303                let mut k_up = f64::INFINITY;
304                for j in (i + 1)..hull_candidates.len() {
305                    let (d_j, f_j, _) = hull_candidates[j];
306                    if d_j > d_i && (d_j - d_i).abs() > 1e-15 {
307                        let slope = (f_j - f_i) / (d_j - d_i);
308                        if slope < k_up {
309                            k_up = slope;
310                        }
311                    }
312                }
313                k_up
314            };
315
316            // Check feasibility: K_upper must be >= K_lower (and >= 0)
317            if k_upper < k_lower {
318                continue; // no feasible K exists
319            }
320
321            // Check improvement condition using K = K_upper (the tightest upper bound).
322            // If K_upper is infinite (no larger rectangles), use K = K_lower >= 0.
323            let k_use = if k_upper.is_finite() {
324                k_upper
325            } else {
326                k_lower
327            };
328            let f_projected = f_i - k_use * d_i;
329            if f_projected <= f_min - epsilon * f_min.abs() {
330                selected.push(idx);
331            }
332        }
333
334        // Always include the rectangle containing the best point
335        // if it's not already selected
336        let best_rect_idx = self
337            .rectangles
338            .iter()
339            .enumerate()
340            .min_by(|(_, a), (_, b)| {
341                a.f_center
342                    .partial_cmp(&b.f_center)
343                    .unwrap_or(std::cmp::Ordering::Equal)
344            })
345            .map(|(i, _)| i);
346
347        if let Some(best_idx) = best_rect_idx {
348            if !selected.contains(&best_idx) {
349                selected.push(best_idx);
350            }
351        }
352
353        selected
354    }
355
356    /// Divide a rectangle along its longest dimensions
357    fn divide_rectangle(&mut self, rect_idx: usize) -> Vec<Rectangle> {
358        let rect = self.rectangles[rect_idx].clone();
359
360        // Find the longest dimension(s)
361        let max_width = rect.half_widths.iter().copied().fold(0.0_f64, f64::max);
362
363        let long_dims: Vec<usize> = rect
364            .half_widths
365            .iter()
366            .enumerate()
367            .filter(|(_, &w)| (w - max_width).abs() < 1e-15)
368            .map(|(i, _)| i)
369            .collect();
370
371        // Standard DIRECT trisection (Jones 1993, Algorithm 1):
372        //
373        // When trisecting a rectangle along dimension d with current half-width W:
374        //
375        //   new_hw = W / 3
376        //
377        //   The original interval [center - W, center + W] is split into three equal
378        //   thirds of width 2*new_hw each:
379        //     Bottom third: [center - W,       center - W/3]  center at center - 2*new_hw
380        //     Middle third: [center - W/3,      center + W/3]  center stays at center
381        //     Top    third: [center + W/3,      center + W  ]  center at center + 2*new_hw
382        //
383        //   So each child rectangle is centered at center ± 2*new_hw with half-width new_hw
384        //   in dimension d, and retains the ORIGINAL parent half-widths in all other dims.
385        //
386        // Jones (1993) determines the trisection order by first evaluating the function
387        // at the BOUNDARY between thirds: center ± new_hw (= center ± W/3).
388        // These probe values w_i = min(f(center ± new_hw * e_d)) rank the dimensions
389        // by how promising their child regions look, without yet committing to full eval.
390        // Child center evaluations (at center ± 2*new_hw) happen separately in the divide step.
391        //
392        // Implementations note: this uses 4 evaluations per long dimension:
393        //   2 at boundary points for sorting + 2 at child centers for child f_center values.
394        let new_hw = max_width / 3.0; // new half-width after trisection
395
396        // Phase 1: evaluate at center ± new_hw (boundary between thirds) for each long dim.
397        // These values rank the dimensions by w_i = min(f+, f-).
398        let mut dim_sort: Vec<(usize, f64)> = Vec::new();
399        for &dim in &long_dims {
400            let mut c_probe_p = rect.center.clone();
401            c_probe_p[dim] += new_hw;
402            let f_probe_p = self.evaluate(&c_probe_p);
403
404            let mut c_probe_m = rect.center.clone();
405            c_probe_m[dim] -= new_hw;
406            let f_probe_m = self.evaluate(&c_probe_m);
407
408            dim_sort.push((dim, f_probe_p.min(f_probe_m)));
409        }
410
411        // Sort long dimensions ascending by w_i (lowest-valued dimension first)
412        dim_sort.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
413
414        // Phase 2: for each long dimension (in sorted order), create the two child
415        // rectangles centered at center ± 2*new_hw.
416        //
417        // Each child inherits the ORIGINAL parent half-widths for all dimensions, except
418        // dimension d which becomes new_hw.  This is correct because the trisection of
419        // one dimension does not affect the extent of the rectangle in other dimensions.
420        let mut new_rects = Vec::new();
421
422        for &(dim, _) in &dim_sort {
423            // Child centers are at the centers of the outer thirds: center ± 2*new_hw
424            let mut c_child_p = rect.center.clone();
425            c_child_p[dim] += 2.0 * new_hw;
426            let f_child_p = self.evaluate(&c_child_p);
427
428            let mut c_child_m = rect.center.clone();
429            c_child_m[dim] -= 2.0 * new_hw;
430            let f_child_m = self.evaluate(&c_child_m);
431
432            // Child half-widths: original parent widths with only dimension d changed
433            let mut hw_child = rect.half_widths.clone();
434            hw_child[dim] = new_hw;
435
436            new_rects.push(Rectangle::new(c_child_p, f_child_p, hw_child.clone()));
437            new_rects.push(Rectangle::new(c_child_m, f_child_m, hw_child));
438        }
439
440        // The parent rectangle (middle third in all trisected dims) keeps its center
441        // but its half-widths shrink to new_hw in every long dimension.
442        let mut parent_hw = rect.half_widths.clone();
443        for &(dim, _) in &dim_sort {
444            parent_hw[dim] = new_hw;
445        }
446        let parent_rect = Rectangle::new(rect.center.clone(), rect.f_center, parent_hw);
447        new_rects.push(parent_rect);
448
449        new_rects
450    }
451
452    /// Run the DIRECT algorithm
453    pub fn run(&mut self) -> DirectResult {
454        self.initialize();
455
456        let mut prev_best_f = self.best_f;
457
458        for iteration in 0..self.options.max_iterations {
459            // Check budget
460            if self.fevals >= self.options.max_fevals {
461                return DirectResult {
462                    x: Array1::from_vec(self.best_x.clone()),
463                    fun: self.best_f,
464                    nfev: self.fevals,
465                    nit: iteration,
466                    n_rectangles: self.rectangles.len(),
467                    success: true,
468                    message: format!(
469                        "Maximum function evaluations ({}) reached",
470                        self.options.max_fevals
471                    ),
472                };
473            }
474
475            // Select potentially optimal rectangles
476            let po_indices = self.select_potentially_optimal();
477            if po_indices.is_empty() {
478                return DirectResult {
479                    x: Array1::from_vec(self.best_x.clone()),
480                    fun: self.best_f,
481                    nfev: self.fevals,
482                    nit: iteration,
483                    n_rectangles: self.rectangles.len(),
484                    success: true,
485                    message: "No potentially optimal rectangles found".to_string(),
486                };
487            }
488
489            // Divide selected rectangles
490            // Sort indices in descending order so removal doesn't affect earlier indices
491            let mut sorted_indices = po_indices;
492            sorted_indices.sort_unstable_by(|a, b| b.cmp(a));
493
494            let mut new_rects_all = Vec::new();
495            for &idx in &sorted_indices {
496                if self.fevals >= self.options.max_fevals {
497                    break;
498                }
499                let new_rects = self.divide_rectangle(idx);
500                new_rects_all.push((idx, new_rects));
501            }
502
503            // Remove old rectangles and add new ones
504            // Remove in descending order
505            let mut indices_to_remove: Vec<usize> =
506                new_rects_all.iter().map(|(idx, _)| *idx).collect();
507            indices_to_remove.sort_unstable_by(|a, b| b.cmp(a));
508            for idx in indices_to_remove {
509                self.rectangles.swap_remove(idx);
510            }
511            for (_, new_rects) in new_rects_all {
512                self.rectangles.extend(new_rects);
513            }
514
515            // Check convergence criteria: only terminate on stagnation after many
516            // consecutive iterations with no improvement.  A single iteration without
517            // improvement is normal in DIRECT (the algorithm explores globally), so we
518            // require sustained stagnation before declaring convergence.
519            let f_improvement = (prev_best_f - self.best_f).abs();
520            let abs_stagnant = f_improvement < self.options.ftol_abs;
521            let rel_stagnant = if prev_best_f.abs() > 1e-30 {
522                f_improvement / prev_best_f.abs() < self.options.ftol_rel
523            } else {
524                abs_stagnant
525            };
526            if (abs_stagnant || rel_stagnant) && iteration > 10 {
527                // Only terminate if we have truly stagnated for many iterations.
528                // Use a heuristic: stagnate for at least 10% of max_iterations or 50 iters.
529                let stagnation_limit = (self.options.max_iterations / 10).max(50);
530                // We track stagnation via consecutive no-improvement: count iterations
531                // since the last improvement by comparing with the running prev_best_f.
532                // Here we use the conservative check: only exit if f has not changed
533                // at all (machine precision) AND enough iterations have passed.
534                if f_improvement == 0.0 && iteration > stagnation_limit {
535                    return DirectResult {
536                        x: Array1::from_vec(self.best_x.clone()),
537                        fun: self.best_f,
538                        nfev: self.fevals,
539                        nit: iteration,
540                        n_rectangles: self.rectangles.len(),
541                        success: true,
542                        message: "Function tolerance reached (stagnation)".to_string(),
543                    };
544                }
545            }
546
547            // Check volume tolerance
548            let max_vol = self
549                .rectangles
550                .iter()
551                .map(|r| r.volume())
552                .fold(0.0_f64, f64::max);
553            if max_vol < self.options.vol_tol {
554                return DirectResult {
555                    x: Array1::from_vec(self.best_x.clone()),
556                    fun: self.best_f,
557                    nfev: self.fevals,
558                    nit: iteration,
559                    n_rectangles: self.rectangles.len(),
560                    success: true,
561                    message: "Volume tolerance reached".to_string(),
562                };
563            }
564
565            prev_best_f = self.best_f;
566        }
567
568        DirectResult {
569            x: Array1::from_vec(self.best_x.clone()),
570            fun: self.best_f,
571            nfev: self.fevals,
572            nit: self.options.max_iterations,
573            n_rectangles: self.rectangles.len(),
574            success: true,
575            message: format!(
576                "Maximum iterations ({}) reached",
577                self.options.max_iterations
578            ),
579        }
580    }
581}
582
583/// Convenience function to run DIRECT optimization
584///
585/// # Arguments
586///
587/// * `func` - Objective function to minimize
588/// * `lower_bounds` - Lower bounds for each dimension
589/// * `upper_bounds` - Upper bounds for each dimension
590/// * `options` - DIRECT options (uses defaults if None)
591///
592/// # Returns
593///
594/// * `DirectResult` with the best solution found
595pub fn direct_minimize<F>(
596    func: F,
597    lower_bounds: Vec<f64>,
598    upper_bounds: Vec<f64>,
599    options: Option<DirectOptions>,
600) -> OptimizeResult<DirectResult>
601where
602    F: Fn(&ArrayView1<f64>) -> f64,
603{
604    let options = options.unwrap_or_default();
605    let mut optimizer = Direct::new(func, lower_bounds, upper_bounds, options)?;
606    Ok(optimizer.run())
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    /// Sphere function
614    fn sphere(x: &ArrayView1<f64>) -> f64 {
615        x.iter().map(|xi| xi * xi).sum()
616    }
617
618    /// Rosenbrock function
619    fn rosenbrock(x: &ArrayView1<f64>) -> f64 {
620        let mut sum = 0.0;
621        for i in 0..x.len() - 1 {
622            sum += 100.0 * (x[i + 1] - x[i] * x[i]).powi(2) + (1.0 - x[i]).powi(2);
623        }
624        sum
625    }
626
627    /// Rastrigin function (multimodal)
628    fn rastrigin(x: &ArrayView1<f64>) -> f64 {
629        let n = x.len() as f64;
630        let mut sum = 10.0 * n;
631        for &xi in x.iter() {
632            sum += xi * xi - 10.0 * (2.0 * std::f64::consts::PI * xi).cos();
633        }
634        sum
635    }
636
637    /// Branin function (3 global minima)
638    fn branin(x: &ArrayView1<f64>) -> f64 {
639        let pi = std::f64::consts::PI;
640        let x1 = x[0];
641        let x2 = x[1];
642        let a = 1.0;
643        let b = 5.1 / (4.0 * pi * pi);
644        let c = 5.0 / pi;
645        let r = 6.0;
646        let s = 10.0;
647        let t = 1.0 / (8.0 * pi);
648        a * (x2 - b * x1 * x1 + c * x1 - r).powi(2) + s * (1.0 - t) * x1.cos() + s
649    }
650
651    #[test]
652    fn test_direct_sphere_2d() {
653        let result = direct_minimize(
654            sphere,
655            vec![-5.0, -5.0],
656            vec![5.0, 5.0],
657            Some(DirectOptions {
658                max_fevals: 500,
659                ..Default::default()
660            }),
661        );
662        assert!(result.is_ok());
663        let res = result.expect("DIRECT sphere 2D failed");
664        assert!(res.fun < 0.1, "DIRECT sphere value: {}", res.fun);
665        // Allow a small overshoot: divide_rectangle() makes up to 4*ndim evaluations per
666        // rectangle in flight when the budget is reached, so the final count may slightly
667        // exceed max_fevals.  Add a slack of 2*4*ndim = 16 to tolerate this.
668        assert!(res.nfev <= 516, "Used {} evaluations", res.nfev);
669    }
670
671    #[test]
672    fn test_direct_sphere_3d() {
673        let result = direct_minimize(
674            sphere,
675            vec![-5.0, -5.0, -5.0],
676            vec![5.0, 5.0, 5.0],
677            Some(DirectOptions {
678                max_fevals: 2_000,
679                ..Default::default()
680            }),
681        );
682        assert!(result.is_ok());
683        let res = result.expect("DIRECT sphere 3D failed");
684        assert!(res.fun < 1.0, "DIRECT sphere 3D value: {}", res.fun);
685    }
686
687    #[test]
688    fn test_direct_rosenbrock() {
689        let result = direct_minimize(
690            rosenbrock,
691            vec![-2.0, -2.0],
692            vec![2.0, 2.0],
693            Some(DirectOptions {
694                max_fevals: 5_000,
695                ..Default::default()
696            }),
697        );
698        assert!(result.is_ok());
699        let res = result.expect("DIRECT Rosenbrock failed");
700        assert!(res.fun < 1.0, "DIRECT Rosenbrock value: {}", res.fun);
701    }
702
703    #[test]
704    fn test_direct_rastrigin() {
705        let result = direct_minimize(
706            rastrigin,
707            vec![-5.12, -5.12],
708            vec![5.12, 5.12],
709            Some(DirectOptions {
710                max_fevals: 5_000,
711                ..Default::default()
712            }),
713        );
714        assert!(result.is_ok());
715        let res = result.expect("DIRECT Rastrigin failed");
716        // DIRECT should find near-global minimum of Rastrigin
717        assert!(res.fun < 5.0, "DIRECT Rastrigin value: {}", res.fun);
718    }
719
720    #[test]
721    fn test_direct_branin() {
722        let result = direct_minimize(
723            branin,
724            vec![-5.0, 0.0],
725            vec![10.0, 15.0],
726            Some(DirectOptions {
727                max_fevals: 3_000,
728                ..Default::default()
729            }),
730        );
731        assert!(result.is_ok());
732        let res = result.expect("DIRECT Branin failed");
733        // Global minimum of Branin is ~0.397887
734        assert!(
735            res.fun < 1.0,
736            "DIRECT Branin value: {} (expected ~0.398)",
737            res.fun
738        );
739    }
740
741    #[test]
742    fn test_direct_locally_biased() {
743        let result = direct_minimize(
744            sphere,
745            vec![-5.0, -5.0],
746            vec![5.0, 5.0],
747            Some(DirectOptions {
748                max_fevals: 500,
749                locally_biased: true,
750                ..Default::default()
751            }),
752        );
753        assert!(result.is_ok());
754        let res = result.expect("DIRECT-L sphere failed");
755        assert!(res.fun < 1.0, "DIRECT-L sphere value: {}", res.fun);
756    }
757
758    #[test]
759    fn test_direct_invalid_bounds() {
760        let result = direct_minimize(sphere, vec![5.0, -5.0], vec![-5.0, 5.0], None);
761        assert!(result.is_err());
762    }
763
764    #[test]
765    fn test_direct_empty_dimensions() {
766        let result: OptimizeResult<DirectResult> = direct_minimize(sphere, vec![], vec![], None);
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_direct_1d() {
772        fn parabola(x: &ArrayView1<f64>) -> f64 {
773            (x[0] - 3.0).powi(2) + 1.0
774        }
775        let result = direct_minimize(
776            parabola,
777            vec![0.0],
778            vec![6.0],
779            Some(DirectOptions {
780                max_fevals: 200,
781                ..Default::default()
782            }),
783        );
784        assert!(result.is_ok());
785        let res = result.expect("DIRECT 1D parabola failed");
786        assert!(
787            (res.x[0] - 3.0).abs() < 0.5,
788            "DIRECT 1D minimum at x={} (expected 3.0)",
789            res.x[0]
790        );
791        assert!(
792            (res.fun - 1.0).abs() < 0.5,
793            "DIRECT 1D value {} (expected 1.0)",
794            res.fun
795        );
796    }
797
798    #[test]
799    fn test_direct_budget_management() {
800        let result = direct_minimize(
801            sphere,
802            vec![-10.0, -10.0],
803            vec![10.0, 10.0],
804            Some(DirectOptions {
805                max_fevals: 50,
806                ..Default::default()
807            }),
808        );
809        assert!(result.is_ok());
810        let res = result.expect("DIRECT budget test failed");
811        assert!(res.nfev <= 55, "Budget exceeded: {} > 50", res.nfev);
812    }
813}