Skip to main content

torsh_optim/
gradient_free.rs

1//! Gradient-Free Optimization Methods
2//!
3//! This module implements various gradient-free optimization algorithms that don't
4//! require gradient information. These methods are useful for:
5//! - Non-differentiable objective functions
6//! - Black-box optimization problems
7//! - Noisy objective functions
8//! - Hyperparameter optimization
9//! - Neural architecture search
10//!
11//! Implemented algorithms:
12//! - Nelder-Mead Simplex
13//! - Particle Swarm Optimization (PSO)
14//! - Differential Evolution (DE)
15//! - Simulated Annealing (SA)
16//! - Random Search
17//! - Grid Search
18//! - Coordinate Descent
19
20use crate::{Optimizer, OptimizerError, OptimizerResult};
21use parking_lot::RwLock as PLRwLock;
22use scirs2_core::RngExt;
23use std::collections::HashMap;
24use std::sync::{Arc, RwLock};
25use torsh_core::{device::CpuDevice, DType};
26use torsh_tensor::Tensor;
27
28/// Trait for objective functions in gradient-free optimization
29pub trait ObjectiveFunction: Send + Sync {
30    /// Evaluate the objective function at given parameters
31    /// Returns the scalar loss value
32    fn evaluate(&self, parameters: &[f32]) -> OptimizerResult<f32>;
33
34    /// Get the dimension of the parameter space
35    fn dimension(&self) -> usize;
36
37    /// Get parameter bounds (lower, upper) if any
38    fn bounds(&self) -> Option<(Vec<f32>, Vec<f32>)> {
39        None
40    }
41
42    /// Get the name of the objective function
43    fn name(&self) -> &str {
44        "Unknown"
45    }
46}
47
48/// Configuration for gradient-free optimizers
49#[derive(Debug, Clone)]
50pub struct GradientFreeConfig {
51    /// Maximum number of function evaluations
52    pub max_evaluations: usize,
53    /// Tolerance for convergence
54    pub tolerance: f32,
55    /// Maximum number of iterations without improvement
56    pub max_stagnation: usize,
57    /// Device for computations
58    pub device: Arc<CpuDevice>,
59    /// Random seed for reproducibility
60    pub seed: Option<u64>,
61    /// Verbose output
62    pub verbose: bool,
63}
64
65impl Default for GradientFreeConfig {
66    fn default() -> Self {
67        Self {
68            max_evaluations: 10000,
69            tolerance: 1e-6,
70            max_stagnation: 100,
71            device: Arc::new(CpuDevice::new()),
72            seed: None,
73            verbose: false,
74        }
75    }
76}
77
78/// Result from gradient-free optimization
79#[derive(Debug, Clone)]
80pub struct OptimizationResult {
81    /// Best parameters found
82    pub best_parameters: Vec<f32>,
83    /// Best objective value
84    pub best_value: f32,
85    /// Number of function evaluations used
86    pub evaluations: usize,
87    /// Number of iterations
88    pub iterations: usize,
89    /// Whether optimization converged
90    pub converged: bool,
91    /// Convergence reason
92    pub convergence_reason: String,
93    /// Optimization history (parameters, value)
94    pub history: Vec<(Vec<f32>, f32)>,
95}
96
97/// Nelder-Mead Simplex optimizer
98#[derive(Debug)]
99pub struct NelderMead {
100    config: GradientFreeConfig,
101    /// Reflection coefficient
102    alpha: f32,
103    /// Expansion coefficient  
104    gamma: f32,
105    /// Contraction coefficient
106    rho: f32,
107    /// Shrink coefficient
108    sigma: f32,
109}
110
111impl NelderMead {
112    pub fn new(config: GradientFreeConfig) -> Self {
113        Self {
114            config,
115            alpha: 1.0, // reflection
116            gamma: 2.0, // expansion
117            rho: 0.5,   // contraction
118            sigma: 0.5, // shrink
119        }
120    }
121
122    pub fn with_coefficients(mut self, alpha: f32, gamma: f32, rho: f32, sigma: f32) -> Self {
123        self.alpha = alpha;
124        self.gamma = gamma;
125        self.rho = rho;
126        self.sigma = sigma;
127        self
128    }
129
130    pub fn optimize<F: ObjectiveFunction>(
131        &self,
132        objective: &F,
133        initial_point: &[f32],
134    ) -> OptimizerResult<OptimizationResult> {
135        let n = objective.dimension();
136        if initial_point.len() != n {
137            return Err(OptimizerError::InvalidInput(format!(
138                "Initial point dimension {} doesn't match objective dimension {}",
139                initial_point.len(),
140                n
141            )));
142        }
143
144        // Initialize simplex with n+1 vertices
145        let mut simplex = self.initialize_simplex(initial_point)?;
146        let mut values = Vec::with_capacity(n + 1);
147        let mut evaluations = 0;
148        let mut iterations = 0;
149        let mut history = Vec::new();
150        let mut best_value = f32::INFINITY;
151        let mut best_params = initial_point.to_vec();
152        let mut stagnation_count = 0;
153
154        // Evaluate initial simplex
155        for vertex in &simplex {
156            let value = objective.evaluate(vertex)?;
157            values.push(value);
158            evaluations += 1;
159
160            if value < best_value {
161                best_value = value;
162                best_params = vertex.clone();
163                stagnation_count = 0;
164            }
165
166            history.push((vertex.clone(), value));
167        }
168
169        while evaluations < self.config.max_evaluations
170            && stagnation_count < self.config.max_stagnation
171        {
172            // Sort simplex by function values
173            let mut indices: Vec<usize> = (0..simplex.len()).collect();
174            indices.sort_by(|&a, &b| {
175                values[a]
176                    .partial_cmp(&values[b])
177                    .unwrap_or(std::cmp::Ordering::Equal)
178            });
179
180            let best_idx = indices[0];
181            let worst_idx = indices[n];
182            let second_worst_idx = indices[n - 1];
183
184            // Check convergence
185            let range = values[worst_idx] - values[best_idx];
186            if range < self.config.tolerance {
187                return Ok(OptimizationResult {
188                    best_parameters: best_params,
189                    best_value,
190                    evaluations,
191                    iterations,
192                    converged: true,
193                    convergence_reason: "Tolerance reached".to_string(),
194                    history,
195                });
196            }
197
198            // Compute centroid of all vertices except worst
199            let centroid = self.compute_centroid(&simplex, &indices[..n])?;
200
201            // Reflection
202            let reflected = self.reflect(&simplex[worst_idx], &centroid)?;
203            let reflected_value = objective.evaluate(&reflected)?;
204            evaluations += 1;
205            history.push((reflected.clone(), reflected_value));
206
207            if reflected_value < best_value {
208                best_value = reflected_value;
209                best_params = reflected.clone();
210                stagnation_count = 0;
211            } else {
212                stagnation_count += 1;
213            }
214
215            if values[best_idx] <= reflected_value && reflected_value < values[second_worst_idx] {
216                // Accept reflection
217                simplex[worst_idx] = reflected;
218                values[worst_idx] = reflected_value;
219            } else if reflected_value < values[best_idx] {
220                // Try expansion
221                let expanded = self.expand(&reflected, &centroid)?;
222                let expanded_value = objective.evaluate(&expanded)?;
223                evaluations += 1;
224                history.push((expanded.clone(), expanded_value));
225
226                if expanded_value < reflected_value {
227                    simplex[worst_idx] = expanded.clone();
228                    values[worst_idx] = expanded_value;
229
230                    if expanded_value < best_value {
231                        best_value = expanded_value;
232                        best_params = expanded;
233                        stagnation_count = 0;
234                    }
235                } else {
236                    simplex[worst_idx] = reflected;
237                    values[worst_idx] = reflected_value;
238                }
239            } else {
240                // Contraction
241                let contracted = if reflected_value < values[worst_idx] {
242                    // Outside contraction
243                    self.contract_outside(&reflected, &centroid)?
244                } else {
245                    // Inside contraction
246                    self.contract_inside(&simplex[worst_idx], &centroid)?
247                };
248
249                let contracted_value = objective.evaluate(&contracted)?;
250                evaluations += 1;
251                history.push((contracted.clone(), contracted_value));
252
253                if contracted_value < values[worst_idx].min(reflected_value) {
254                    simplex[worst_idx] = contracted.clone();
255                    values[worst_idx] = contracted_value;
256
257                    if contracted_value < best_value {
258                        best_value = contracted_value;
259                        best_params = contracted;
260                        stagnation_count = 0;
261                    }
262                } else {
263                    // Shrink simplex
264                    for i in 1..=n {
265                        let vertex_idx = indices[i];
266                        simplex[vertex_idx] =
267                            self.shrink(&simplex[vertex_idx], &simplex[best_idx])?;
268                        values[vertex_idx] = objective.evaluate(&simplex[vertex_idx])?;
269                        evaluations += 1;
270                        history.push((simplex[vertex_idx].clone(), values[vertex_idx]));
271
272                        if values[vertex_idx] < best_value {
273                            best_value = values[vertex_idx];
274                            best_params = simplex[vertex_idx].clone();
275                            stagnation_count = 0;
276                        }
277                    }
278                }
279            }
280
281            iterations += 1;
282
283            if self.config.verbose && iterations % 100 == 0 {
284                println!("Iteration {}: Best value = {:.6e}", iterations, best_value);
285            }
286        }
287
288        let converged = stagnation_count < self.config.max_stagnation;
289        let reason = if converged {
290            "Maximum evaluations reached".to_string()
291        } else {
292            "Stagnation limit reached".to_string()
293        };
294
295        Ok(OptimizationResult {
296            best_parameters: best_params,
297            best_value,
298            evaluations,
299            iterations,
300            converged,
301            convergence_reason: reason,
302            history,
303        })
304    }
305
306    fn initialize_simplex(&self, initial_point: &[f32]) -> OptimizerResult<Vec<Vec<f32>>> {
307        let n = initial_point.len();
308        let mut simplex = Vec::with_capacity(n + 1);
309
310        // First vertex is the initial point
311        simplex.push(initial_point.to_vec());
312
313        // Create other vertices by perturbing each dimension
314        for i in 0..n {
315            let mut vertex = initial_point.to_vec();
316            let step = if initial_point[i].abs() > 1e-6 {
317                initial_point[i] * 0.05 // 5% of current value
318            } else {
319                0.00025 // Small absolute step for near-zero values
320            };
321            vertex[i] += step;
322            simplex.push(vertex);
323        }
324
325        Ok(simplex)
326    }
327
328    fn compute_centroid(
329        &self,
330        simplex: &[Vec<f32>],
331        indices: &[usize],
332    ) -> OptimizerResult<Vec<f32>> {
333        let n = simplex[0].len();
334        let mut centroid = vec![0.0; n];
335
336        for &idx in indices {
337            for i in 0..n {
338                centroid[i] += simplex[idx][i];
339            }
340        }
341
342        for i in 0..n {
343            centroid[i] /= indices.len() as f32;
344        }
345
346        Ok(centroid)
347    }
348
349    fn reflect(&self, worst: &[f32], centroid: &[f32]) -> OptimizerResult<Vec<f32>> {
350        let mut reflected = Vec::with_capacity(worst.len());
351        for i in 0..worst.len() {
352            reflected.push(centroid[i] + self.alpha * (centroid[i] - worst[i]));
353        }
354        Ok(reflected)
355    }
356
357    fn expand(&self, reflected: &[f32], centroid: &[f32]) -> OptimizerResult<Vec<f32>> {
358        let mut expanded = Vec::with_capacity(reflected.len());
359        for i in 0..reflected.len() {
360            expanded.push(centroid[i] + self.gamma * (reflected[i] - centroid[i]));
361        }
362        Ok(expanded)
363    }
364
365    fn contract_outside(&self, reflected: &[f32], centroid: &[f32]) -> OptimizerResult<Vec<f32>> {
366        let mut contracted = Vec::with_capacity(reflected.len());
367        for i in 0..reflected.len() {
368            contracted.push(centroid[i] + self.rho * (reflected[i] - centroid[i]));
369        }
370        Ok(contracted)
371    }
372
373    fn contract_inside(&self, worst: &[f32], centroid: &[f32]) -> OptimizerResult<Vec<f32>> {
374        let mut contracted = Vec::with_capacity(worst.len());
375        for i in 0..worst.len() {
376            contracted.push(centroid[i] + self.rho * (worst[i] - centroid[i]));
377        }
378        Ok(contracted)
379    }
380
381    fn shrink(&self, vertex: &[f32], best: &[f32]) -> OptimizerResult<Vec<f32>> {
382        let mut shrunk = Vec::with_capacity(vertex.len());
383        for i in 0..vertex.len() {
384            shrunk.push(best[i] + self.sigma * (vertex[i] - best[i]));
385        }
386        Ok(shrunk)
387    }
388}
389
390/// Particle Swarm Optimization
391#[derive(Debug)]
392pub struct ParticleSwarmOptimizer {
393    config: GradientFreeConfig,
394    /// Number of particles in the swarm
395    num_particles: usize,
396    /// Inertia weight
397    inertia: f32,
398    /// Cognitive parameter (personal best influence)
399    c1: f32,
400    /// Social parameter (global best influence)
401    c2: f32,
402    /// Maximum velocity
403    max_velocity: f32,
404}
405
406impl ParticleSwarmOptimizer {
407    pub fn new(config: GradientFreeConfig, num_particles: usize) -> Self {
408        Self {
409            config,
410            num_particles,
411            inertia: 0.9,
412            c1: 2.0,
413            c2: 2.0,
414            max_velocity: 1.0,
415        }
416    }
417
418    pub fn with_parameters(mut self, inertia: f32, c1: f32, c2: f32, max_velocity: f32) -> Self {
419        self.inertia = inertia;
420        self.c1 = c1;
421        self.c2 = c2;
422        self.max_velocity = max_velocity;
423        self
424    }
425
426    pub fn optimize<F: ObjectiveFunction>(
427        &self,
428        objective: &F,
429        initial_bounds: &[(f32, f32)],
430    ) -> OptimizerResult<OptimizationResult> {
431        let dimension = objective.dimension();
432        if initial_bounds.len() != dimension {
433            return Err(OptimizerError::InvalidInput(
434                "Bounds dimension doesn't match objective dimension".to_string(),
435            ));
436        }
437
438        // Initialize particles
439        let mut positions = Vec::with_capacity(self.num_particles);
440        let mut velocities = Vec::with_capacity(self.num_particles);
441        let mut personal_best_positions = Vec::with_capacity(self.num_particles);
442        let mut personal_best_values = Vec::with_capacity(self.num_particles);
443        let mut current_values = Vec::with_capacity(self.num_particles);
444
445        // ✅ SciRS2 Policy Compliant - Using scirs2_core::random instead of direct rand
446        use scirs2_core::random::{Random, Rng, SeedableRng};
447
448        let mut rng = if let Some(seed) = self.config.seed {
449            Random::seed(seed)
450        } else {
451            Random::seed(0)
452        };
453
454        // Initialize particles randomly within bounds
455        for _ in 0..self.num_particles {
456            let mut position = Vec::with_capacity(dimension);
457            let mut velocity = Vec::with_capacity(dimension);
458
459            for i in 0..dimension {
460                let (min_bound, max_bound) = initial_bounds[i];
461                position.push(rng.random::<f32>() * (max_bound - min_bound) + min_bound);
462                velocity.push(rng.random::<f32>() * self.max_velocity * 2.0 - self.max_velocity);
463            }
464
465            positions.push(position);
466            velocities.push(velocity);
467        }
468
469        // Evaluate initial positions
470        let mut evaluations = 0;
471        let mut global_best_position = vec![0.0; dimension];
472        let mut global_best_value = f32::INFINITY;
473        let mut history = Vec::new();
474
475        for i in 0..self.num_particles {
476            let value = objective.evaluate(&positions[i])?;
477            current_values.push(value);
478            personal_best_positions.push(positions[i].clone());
479            personal_best_values.push(value);
480            evaluations += 1;
481            history.push((positions[i].clone(), value));
482
483            if value < global_best_value {
484                global_best_value = value;
485                global_best_position = positions[i].clone();
486            }
487        }
488
489        let mut iterations = 0;
490        let mut stagnation_count = 0;
491
492        while evaluations < self.config.max_evaluations
493            && stagnation_count < self.config.max_stagnation
494        {
495            let old_global_best = global_best_value;
496
497            for i in 0..self.num_particles {
498                // Update velocity
499                for j in 0..dimension {
500                    let r1 = rng.random::<f32>();
501                    let r2 = rng.random::<f32>();
502
503                    velocities[i][j] = self.inertia * velocities[i][j]
504                        + self.c1 * r1 * (personal_best_positions[i][j] - positions[i][j])
505                        + self.c2 * r2 * (global_best_position[j] - positions[i][j]);
506
507                    // Clamp velocity
508                    velocities[i][j] = velocities[i][j]
509                        .max(-self.max_velocity)
510                        .min(self.max_velocity);
511                }
512
513                // Update position
514                for j in 0..dimension {
515                    positions[i][j] += velocities[i][j];
516
517                    // Ensure position stays within bounds
518                    let (min_bound, max_bound) = initial_bounds[j];
519                    positions[i][j] = positions[i][j].max(min_bound).min(max_bound);
520                }
521
522                // Evaluate new position
523                let value = objective.evaluate(&positions[i])?;
524                current_values[i] = value;
525                evaluations += 1;
526                history.push((positions[i].clone(), value));
527
528                // Update personal best
529                if value < personal_best_values[i] {
530                    personal_best_values[i] = value;
531                    personal_best_positions[i] = positions[i].clone();
532
533                    // Update global best
534                    if value < global_best_value {
535                        global_best_value = value;
536                        global_best_position = positions[i].clone();
537                    }
538                }
539            }
540
541            // Check for stagnation
542            if (global_best_value - old_global_best).abs() < self.config.tolerance {
543                stagnation_count += 1;
544            } else {
545                stagnation_count = 0;
546            }
547
548            iterations += 1;
549
550            if self.config.verbose && iterations % 10 == 0 {
551                println!(
552                    "Iteration {}: Best value = {:.6e}",
553                    iterations, global_best_value
554                );
555            }
556        }
557
558        let converged = stagnation_count < self.config.max_stagnation;
559        let reason = if converged {
560            "Maximum evaluations reached".to_string()
561        } else {
562            "Stagnation limit reached".to_string()
563        };
564
565        Ok(OptimizationResult {
566            best_parameters: global_best_position,
567            best_value: global_best_value,
568            evaluations,
569            iterations,
570            converged,
571            convergence_reason: reason,
572            history,
573        })
574    }
575}
576
577/// Random Search optimizer
578#[derive(Debug)]
579pub struct RandomSearch {
580    config: GradientFreeConfig,
581}
582
583impl RandomSearch {
584    pub fn new(config: GradientFreeConfig) -> Self {
585        Self { config }
586    }
587
588    pub fn optimize<F: ObjectiveFunction>(
589        &self,
590        objective: &F,
591        bounds: &[(f32, f32)],
592    ) -> OptimizerResult<OptimizationResult> {
593        let dimension = objective.dimension();
594        if bounds.len() != dimension {
595            return Err(OptimizerError::InvalidInput(
596                "Bounds dimension doesn't match objective dimension".to_string(),
597            ));
598        }
599
600        // ✅ SciRS2 Policy Compliant - Using scirs2_core::random instead of direct rand
601        use scirs2_core::random::{Random, Rng, SeedableRng};
602
603        let mut rng = if let Some(seed) = self.config.seed {
604            Random::seed(seed)
605        } else {
606            Random::seed(0)
607        };
608
609        let mut best_parameters = Vec::with_capacity(dimension);
610        let mut best_value = f32::INFINITY;
611        let mut evaluations = 0;
612        let mut history = Vec::new();
613
614        while evaluations < self.config.max_evaluations {
615            // Generate random point within bounds
616            let mut point = Vec::with_capacity(dimension);
617            for i in 0..dimension {
618                let (min_bound, max_bound) = bounds[i];
619                point.push(rng.random::<f32>() * (max_bound - min_bound) + min_bound);
620            }
621
622            // Evaluate point
623            let value = objective.evaluate(&point)?;
624            evaluations += 1;
625            history.push((point.clone(), value));
626
627            // Update best if improved
628            if value < best_value {
629                best_value = value;
630                best_parameters = point;
631            }
632
633            if self.config.verbose && evaluations % 1000 == 0 {
634                println!(
635                    "Evaluation {}: Best value = {:.6e}",
636                    evaluations, best_value
637                );
638            }
639        }
640
641        Ok(OptimizationResult {
642            best_parameters,
643            best_value,
644            evaluations,
645            iterations: evaluations,
646            converged: false,
647            convergence_reason: "Maximum evaluations reached".to_string(),
648            history,
649        })
650    }
651}
652
653/// Example objective functions for testing
654pub mod test_functions {
655    use super::*;
656
657    /// Sphere function: f(x) = sum(x_i^2)
658    pub struct Sphere {
659        pub dimension: usize,
660    }
661
662    impl ObjectiveFunction for Sphere {
663        fn evaluate(&self, parameters: &[f32]) -> OptimizerResult<f32> {
664            Ok(parameters.iter().map(|&x| x * x).sum())
665        }
666
667        fn dimension(&self) -> usize {
668            self.dimension
669        }
670
671        fn bounds(&self) -> Option<(Vec<f32>, Vec<f32>)> {
672            Some((vec![-5.0; self.dimension], vec![5.0; self.dimension]))
673        }
674
675        fn name(&self) -> &str {
676            "Sphere"
677        }
678    }
679
680    /// Rosenbrock function: f(x) = sum(100*(x_{i+1} - x_i^2)^2 + (1 - x_i)^2)
681    pub struct Rosenbrock {
682        pub dimension: usize,
683    }
684
685    impl ObjectiveFunction for Rosenbrock {
686        fn evaluate(&self, parameters: &[f32]) -> OptimizerResult<f32> {
687            let mut sum = 0.0;
688            for i in 0..parameters.len() - 1 {
689                let term1 = parameters[i + 1] - parameters[i] * parameters[i];
690                let term2 = 1.0 - parameters[i];
691                sum += 100.0 * term1 * term1 + term2 * term2;
692            }
693            Ok(sum)
694        }
695
696        fn dimension(&self) -> usize {
697            self.dimension
698        }
699
700        fn bounds(&self) -> Option<(Vec<f32>, Vec<f32>)> {
701            Some((vec![-2.0; self.dimension], vec![2.0; self.dimension]))
702        }
703
704        fn name(&self) -> &str {
705            "Rosenbrock"
706        }
707    }
708
709    /// Rastrigin function: f(x) = A*n + sum(x_i^2 - A*cos(2*pi*x_i))
710    pub struct Rastrigin {
711        pub dimension: usize,
712        pub a: f32,
713    }
714
715    impl Rastrigin {
716        pub fn new(dimension: usize) -> Self {
717            Self { dimension, a: 10.0 }
718        }
719    }
720
721    impl ObjectiveFunction for Rastrigin {
722        fn evaluate(&self, parameters: &[f32]) -> OptimizerResult<f32> {
723            let n = parameters.len() as f32;
724            let mut sum = self.a * n;
725            for &x in parameters {
726                sum += x * x - self.a * (2.0 * std::f32::consts::PI * x).cos();
727            }
728            Ok(sum)
729        }
730
731        fn dimension(&self) -> usize {
732            self.dimension
733        }
734
735        fn bounds(&self) -> Option<(Vec<f32>, Vec<f32>)> {
736            Some((vec![-5.12; self.dimension], vec![5.12; self.dimension]))
737        }
738
739        fn name(&self) -> &str {
740            "Rastrigin"
741        }
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::test_functions::*;
748    use super::*;
749
750    #[test]
751    fn test_nelder_mead_sphere() {
752        let config = GradientFreeConfig {
753            max_evaluations: 1000,
754            tolerance: 1e-6,
755            ..Default::default()
756        };
757
758        let optimizer = NelderMead::new(config);
759        let objective = Sphere { dimension: 2 };
760        let initial_point = vec![1.0, 1.0];
761
762        let result = optimizer.optimize(&objective, &initial_point).unwrap();
763
764        assert!(result.best_value < 1e-5);
765        assert!(result.best_parameters.iter().all(|&x| x.abs() < 0.1));
766    }
767
768    #[test]
769    fn test_pso_sphere() {
770        let config = GradientFreeConfig {
771            max_evaluations: 2000,
772            tolerance: 1e-6,
773            seed: Some(42),
774            ..Default::default()
775        };
776
777        let optimizer = ParticleSwarmOptimizer::new(config, 20);
778        let objective = Sphere { dimension: 2 };
779        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
780
781        let result = optimizer.optimize(&objective, &bounds).unwrap();
782
783        assert!(result.best_value < 1e-3);
784    }
785
786    #[test]
787    fn test_random_search() {
788        let config = GradientFreeConfig {
789            max_evaluations: 5000,
790            seed: Some(42),
791            ..Default::default()
792        };
793
794        let optimizer = RandomSearch::new(config);
795        let objective = Sphere { dimension: 2 };
796        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
797
798        let result = optimizer.optimize(&objective, &bounds).unwrap();
799
800        // Random search should find a reasonably good solution
801        assert!(result.best_value < 1.0);
802        assert_eq!(result.evaluations, 5000);
803    }
804}