autoeq_de/
mod.rs

1#![doc = include_str!("../README.md")]
2#![doc = include_str!("../REFERENCES.md")]
3#![allow(missing_docs)]
4use std::fmt;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use ndarray::{Array1, Array2};
9use rand::rngs::StdRng;
10use rand::{Rng, SeedableRng};
11use std::time::Instant;
12
13pub mod stack_linear_penalty;
14
15pub mod apply_integrality;
16pub mod apply_wls;
17
18pub mod distinct_indices;
19pub mod init_latin_hypercube;
20pub mod init_random;
21
22pub mod mutant_adaptive;
23pub mod mutant_best1;
24pub mod mutant_best2;
25pub mod mutant_current_to_best1;
26pub mod mutant_rand1;
27pub mod mutant_rand2;
28pub mod mutant_rand_to_best1;
29
30pub mod crossover_binomial;
31pub mod crossover_exponential;
32
33pub mod differential_evolution;
34pub mod function_registry;
35pub mod impl_helpers;
36pub mod metadata;
37pub mod parallel_eval;
38pub mod recorder;
39pub mod run_recorded;
40pub use differential_evolution::differential_evolution;
41pub use parallel_eval::ParallelConfig;
42pub use recorder::{OptimizationRecord, OptimizationRecorder};
43pub use run_recorded::run_recorded_differential_evolution;
44
45// Type aliases to reduce complexity
46/// Scalar constraint function type
47pub type ScalarConstraintFn = Arc<dyn Fn(&Array1<f64>) -> f64 + Send + Sync>;
48/// Vector constraint function type
49pub type VectorConstraintFn = Arc<dyn Fn(&Array1<f64>) -> Array1<f64> + Send + Sync>;
50/// Penalty tuple type (function, weight)
51pub type PenaltyTuple = (ScalarConstraintFn, f64);
52/// Callback function type
53pub type CallbackFn = Box<dyn FnMut(&DEIntermediate) -> CallbackAction>;
54
55pub(crate) fn argmin(v: &Array1<f64>) -> (usize, f64) {
56    let mut best_i = 0usize;
57    let mut best_v = v[0];
58    for (i, &val) in v.iter().enumerate() {
59        if val < best_v {
60            best_v = val;
61            best_i = i;
62        }
63    }
64    (best_i, best_v)
65}
66
67/// Differential Evolution strategy
68#[derive(Debug, Clone, Copy)]
69pub enum Strategy {
70    Best1Bin,
71    Best1Exp,
72    Rand1Bin,
73    Rand1Exp,
74    Rand2Bin,
75    Rand2Exp,
76    CurrentToBest1Bin,
77    CurrentToBest1Exp,
78    Best2Bin,
79    Best2Exp,
80    RandToBest1Bin,
81    RandToBest1Exp,
82    /// Adaptive mutation based on the SAM approach: dynamic sampling from top w% individuals
83    /// where w decreases linearly from w_max to w_min based on current iteration
84    AdaptiveBin,
85    /// Adaptive mutation with exponential crossover
86    AdaptiveExp,
87}
88
89impl FromStr for Strategy {
90    type Err = String;
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        let t = s.to_lowercase();
93        match t.as_str() {
94            "best1bin" | "best1" => Ok(Strategy::Best1Bin),
95            "best1exp" => Ok(Strategy::Best1Exp),
96            "rand1bin" | "rand1" => Ok(Strategy::Rand1Bin),
97            "rand1exp" => Ok(Strategy::Rand1Exp),
98            "rand2bin" | "rand2" => Ok(Strategy::Rand2Bin),
99            "rand2exp" => Ok(Strategy::Rand2Exp),
100            "currenttobest1bin" | "current-to-best1bin" | "current_to_best1bin" => {
101                Ok(Strategy::CurrentToBest1Bin)
102            }
103            "currenttobest1exp" | "current-to-best1exp" | "current_to_best1exp" => {
104                Ok(Strategy::CurrentToBest1Exp)
105            }
106            "best2bin" | "best2" => Ok(Strategy::Best2Bin),
107            "best2exp" => Ok(Strategy::Best2Exp),
108            "randtobest1bin" | "rand-to-best1bin" | "rand_to_best1bin" => {
109                Ok(Strategy::RandToBest1Bin)
110            }
111            "randtobest1exp" | "rand-to-best1exp" | "rand_to_best1exp" => {
112                Ok(Strategy::RandToBest1Exp)
113            }
114            "adaptivebin" | "adaptive-bin" | "adaptive_bin" | "adaptive" => {
115                Ok(Strategy::AdaptiveBin)
116            }
117            "adaptiveexp" | "adaptive-exp" | "adaptive_exp" => Ok(Strategy::AdaptiveExp),
118            _ => Err(format!("unknown strategy: {}", s)),
119        }
120    }
121}
122
123/// Crossover type
124#[derive(Debug, Clone, Copy, Default)]
125pub enum Crossover {
126    /// Binomial (uniform) crossover
127    #[default]
128    Binomial,
129    /// Exponential crossover
130    Exponential,
131}
132
133/// Mutation setting: either a fixed factor, a uniform range (dithering), or adaptive
134#[derive(Debug, Clone, Copy)]
135pub enum Mutation {
136    /// Fixed mutation factor F in [0, 2)
137    Factor(f64),
138    /// Dithering range [min, max) with 0 <= min < max <= 2
139    Range { min: f64, max: f64 },
140    /// Adaptive mutation factor using Cauchy distribution with location parameter tracking
141    Adaptive { initial_f: f64 },
142}
143
144impl Default for Mutation {
145    fn default() -> Self {
146        let _ = Mutation::Factor(0.8);
147        Mutation::Range { min: 0.0, max: 2.0 }
148    }
149}
150
151impl Mutation {
152    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
153        match *self {
154            Mutation::Factor(f) => f,
155            Mutation::Range { min, max } => rng.random_range(min..max),
156            Mutation::Adaptive { initial_f } => initial_f, // Will be overridden by adaptive logic
157        }
158    }
159
160    /// Sample from Cauchy distribution for adaptive mutation (F parameter)
161    #[allow(dead_code)]
162    fn sample_cauchy<R: Rng + ?Sized>(&self, f_m: f64, _scale: f64, rng: &mut R) -> f64 {
163        // Simplified version using normal random for now
164        let perturbation = (rng.random::<f64>() - 0.5) * 0.2; // Small perturbation
165        (f_m + perturbation).clamp(0.0, 2.0) // Clamp to valid range
166    }
167}
168
169/// Initialization scheme for the population
170#[derive(Debug, Clone, Copy, Default)]
171pub enum Init {
172    #[default]
173    LatinHypercube,
174    Random,
175}
176
177/// Whether best updates during a generation (we use Deferred only)
178#[derive(Debug, Clone, Copy, Default)]
179pub enum Updating {
180    #[default]
181    Deferred,
182}
183
184/// Linear penalty specification: lb <= A x <= ub (component-wise)
185#[derive(Debug, Clone)]
186pub struct LinearPenalty {
187    pub a: Array2<f64>,
188    pub lb: Array1<f64>,
189    pub ub: Array1<f64>,
190    pub weight: f64,
191}
192
193/// SciPy-like linear constraint helper: lb <= A x <= ub
194#[derive(Debug, Clone)]
195pub struct LinearConstraintHelper {
196    pub a: Array2<f64>,
197    pub lb: Array1<f64>,
198    pub ub: Array1<f64>,
199}
200
201impl LinearConstraintHelper {
202    /// Apply helper by merging into DEConfig.linear_penalty (stacking rows if already present)
203    pub fn apply_to(&self, cfg: &mut DEConfig, weight: f64) {
204        use stack_linear_penalty::stack_linear_penalty;
205
206        let new_lp = LinearPenalty {
207            a: self.a.clone(),
208            lb: self.lb.clone(),
209            ub: self.ub.clone(),
210            weight,
211        };
212        match &mut cfg.linear_penalty {
213            Some(existing) => stack_linear_penalty(existing, &new_lp),
214            None => cfg.linear_penalty = Some(new_lp),
215        }
216    }
217}
218
219/// SciPy-like nonlinear constraint helper: vector-valued fun(x) with lb <= fun(x) <= ub
220#[derive(Clone)]
221pub struct NonlinearConstraintHelper {
222    pub fun: VectorConstraintFn,
223    pub lb: Array1<f64>,
224    pub ub: Array1<f64>,
225}
226
227impl NonlinearConstraintHelper {
228    /// Apply helper by emitting penalty closures per component.
229    /// lb <= f_i(x) <= ub becomes two inequalities: f_i(x)-ub <= 0 and lb - f_i(x) <= 0.
230    /// If lb==ub, emit an equality penalty for f_i(x)-lb.
231    pub fn apply_to(&self, cfg: &mut DEConfig, weight_ineq: f64, weight_eq: f64) {
232        let f = self.fun.clone();
233        let lb = self.lb.clone();
234        let ub = self.ub.clone();
235        let m = lb.len().min(ub.len());
236        for i in 0..m {
237            let l = lb[i];
238            let u = ub[i];
239            if (u - l).abs() < 1e-18 {
240                let fi = f.clone();
241                cfg.penalty_eq.push((
242                    Arc::new(move |x: &Array1<f64>| {
243                        let y = (fi)(x);
244                        y[i] - l
245                    }),
246                    weight_eq,
247                ));
248            } else {
249                let fi_u = f.clone();
250                cfg.penalty_ineq.push((
251                    Arc::new(move |x: &Array1<f64>| {
252                        let y = (fi_u)(x);
253                        y[i] - u
254                    }),
255                    weight_ineq,
256                ));
257                let fi_l = f.clone();
258                cfg.penalty_ineq.push((
259                    Arc::new(move |x: &Array1<f64>| {
260                        let y = (fi_l)(x);
261                        l - y[i]
262                    }),
263                    weight_ineq,
264                ));
265            }
266        }
267    }
268}
269
270/// Structures for tracking adaptive parameters
271#[derive(Debug, Clone)]
272struct AdaptiveState {
273    /// Current F_m parameter for Cauchy distribution (mutation)
274    f_m: f64,
275    /// Current CR_m parameter for Gaussian distribution (crossover)
276    cr_m: f64,
277    /// Successful F values from this generation
278    successful_f: Vec<f64>,
279    /// Successful CR values from this generation
280    successful_cr: Vec<f64>,
281    /// Current linearly decreasing weight for adaptive mutation
282    current_w: f64,
283}
284
285impl AdaptiveState {
286    fn new(config: &AdaptiveConfig) -> Self {
287        Self {
288            f_m: config.f_m,
289            cr_m: config.cr_m,
290            successful_f: Vec::new(),
291            successful_cr: Vec::new(),
292            current_w: config.w_max, // Start with maximum weight
293        }
294    }
295
296    /// Update adaptive parameters based on successful trials
297    fn update(&mut self, config: &AdaptiveConfig, iter: usize, max_iter: usize) {
298        // Update linearly decreasing weight (Equation 19 from the paper)
299        let iter_ratio = iter as f64 / max_iter as f64;
300        self.current_w = config.w_max - (config.w_max - config.w_min) * iter_ratio;
301
302        // Update F_m using Lehmer mean of successful F values
303        if !self.successful_f.is_empty() {
304            let mean_f = self.compute_lehmer_mean(&self.successful_f);
305            self.f_m = (1.0 - config.w_f) * self.f_m + config.w_f * mean_f;
306            self.f_m = self.f_m.clamp(0.2, 1.2);
307        }
308
309        // Update CR_m using arithmetic mean of successful CR values
310        if !self.successful_cr.is_empty() {
311            let mean_cr = self.compute_arithmetic_mean(&self.successful_cr);
312            self.cr_m = (1.0 - config.w_cr) * self.cr_m + config.w_cr * mean_cr;
313            self.cr_m = self.cr_m.clamp(0.1, 0.9);
314        }
315
316        // Clear successful values for next generation
317        self.successful_f.clear();
318        self.successful_cr.clear();
319    }
320
321    /// Compute Lehmer mean for successful F values (p=2)
322    fn compute_lehmer_mean(&self, values: &[f64]) -> f64 {
323        if values.is_empty() {
324            return 0.5; // Default fallback
325        }
326
327        let sum_sq: f64 = values.iter().map(|&x| x * x).sum();
328        let sum: f64 = values.iter().sum();
329
330        if sum > 0.0 {
331            sum_sq / sum
332        } else {
333            0.5 // Fallback if sum is zero
334        }
335    }
336
337    /// Compute arithmetic mean for successful CR values
338    fn compute_arithmetic_mean(&self, values: &[f64]) -> f64 {
339        if values.is_empty() {
340            return 0.5; // Default fallback
341        }
342        values.iter().sum::<f64>() / values.len() as f64
343    }
344
345    /// Record successful parameter values
346    fn record_success(&mut self, f_val: f64, cr_val: f64) {
347        self.successful_f.push(f_val);
348        self.successful_cr.push(cr_val);
349    }
350
351    /// Sample adaptive F parameter using conservative normal distribution
352    fn sample_f<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
353        // Use simple normal distribution with smaller variance for stability
354        let u1: f64 = rng.random();
355        let u2: f64 = rng.random();
356
357        // Box-Muller transform with smaller standard deviation
358        let normal = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
359        let sample = self.f_m + 0.05 * normal; // Reduced variance from 0.1 to 0.05
360
361        // Clamp to conservative bounds
362        sample.clamp(0.3, 1.0)
363    }
364
365    /// Sample adaptive CR parameter using conservative Gaussian distribution
366    fn sample_cr<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
367        // Use normal distribution with smaller variance for stability
368        let u1: f64 = rng.random();
369        let u2: f64 = rng.random();
370
371        // Box-Muller transform with smaller standard deviation
372        let normal = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
373        let sample = self.cr_m + 0.05 * normal; // Reduced variance from 0.1 to 0.05
374
375        sample.clamp(0.1, 0.9)
376    }
377}
378
379/// Adaptive differential evolution configuration
380#[derive(Debug, Clone)]
381pub struct AdaptiveConfig {
382    /// Enable adaptive mutation strategy
383    pub adaptive_mutation: bool,
384    /// Enable Wrapper Local Search (WLS)
385    pub wls_enabled: bool,
386    /// Maximum weight for adaptive mutation (w_max)
387    pub w_max: f64,
388    /// Minimum weight for adaptive mutation (w_min)
389    pub w_min: f64,
390    /// Weight factor for F parameter adaptation (between 0.8 and 1.0)
391    pub w_f: f64,
392    /// Weight factor for CR parameter adaptation (between 0.9 and 1.0)
393    pub w_cr: f64,
394    /// Initial location parameter for Cauchy distribution (F_m)
395    pub f_m: f64,
396    /// Initial location parameter for Gaussian distribution (CR_m)
397    pub cr_m: f64,
398    /// WLS probability (what fraction of population to apply WLS to)
399    pub wls_prob: f64,
400    /// WLS Cauchy scale parameter
401    pub wls_scale: f64,
402}
403
404impl Default for AdaptiveConfig {
405    fn default() -> Self {
406        Self {
407            adaptive_mutation: false,
408            wls_enabled: false,
409            w_max: 0.9,
410            w_min: 0.1,
411            w_f: 0.9,
412            w_cr: 0.9,
413            f_m: 0.5,
414            cr_m: 0.6,
415            wls_prob: 0.1,
416            wls_scale: 0.1,
417        }
418    }
419}
420
421/// Polishing configuration using NLopt local optimizer within bounds
422#[derive(Debug, Clone)]
423pub struct PolishConfig {
424    pub enabled: bool,
425    pub algo: String,   // e.g., "neldermead", "sbplx", "cobyla"
426    pub maxeval: usize, // e.g., 200*n
427}
428
429/// Configuration for the Differential Evolution optimizer
430pub struct DEConfig {
431    pub maxiter: usize,
432    pub popsize: usize, // total NP = popsize * n_params_free
433    pub tol: f64,
434    pub atol: f64,
435    pub mutation: Mutation,
436    pub recombination: f64, // CR in [0,1]
437    pub strategy: Strategy,
438    pub crossover: Crossover,
439    pub init: Init,
440    pub updating: Updating,
441    pub seed: Option<u64>,
442    /// Optional integrality mask; true => variable is integer-constrained
443    pub integrality: Option<Vec<bool>>,
444    /// Optional initial guess used to replace the best member after init
445    pub x0: Option<Array1<f64>>,
446    /// Print objective best at each iteration
447    pub disp: bool,
448    /// Optional per-iteration callback (may stop early)
449    pub callback: Option<CallbackFn>,
450    /// Penalty-based inequality constraints: fc(x) <= 0
451    pub penalty_ineq: Vec<PenaltyTuple>,
452    /// Penalty-based equality constraints: h(x) = 0
453    pub penalty_eq: Vec<PenaltyTuple>,
454    /// Optional linear constraints treated by penalty: lb <= A x <= ub (component-wise)
455    pub linear_penalty: Option<LinearPenalty>,
456    /// Polishing configuration (optional)
457    pub polish: Option<PolishConfig>,
458    /// Adaptive differential evolution configuration
459    pub adaptive: AdaptiveConfig,
460    /// Parallel evaluation configuration
461    pub parallel: parallel_eval::ParallelConfig,
462}
463
464impl Default for DEConfig {
465    fn default() -> Self {
466        Self {
467            maxiter: 1000,
468            popsize: 15,
469            tol: 1e-2,
470            atol: 0.0,
471            mutation: Mutation::default(),
472            recombination: 0.7,
473            strategy: Strategy::Best1Bin,
474            crossover: Crossover::default(),
475            init: Init::default(),
476            updating: Updating::default(),
477            seed: None,
478            integrality: None,
479            x0: None,
480            disp: false,
481            callback: None,
482            penalty_ineq: Vec::new(),
483            penalty_eq: Vec::new(),
484            linear_penalty: None,
485            polish: None,
486            adaptive: AdaptiveConfig::default(),
487            parallel: parallel_eval::ParallelConfig::default(),
488        }
489    }
490}
491
492/// Fluent builder for `DEConfig` for ergonomic configuration.
493pub struct DEConfigBuilder {
494    cfg: DEConfig,
495}
496impl Default for DEConfigBuilder {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502impl DEConfigBuilder {
503    pub fn new() -> Self {
504        Self {
505            cfg: DEConfig::default(),
506        }
507    }
508    pub fn maxiter(mut self, v: usize) -> Self {
509        self.cfg.maxiter = v;
510        self
511    }
512    pub fn popsize(mut self, v: usize) -> Self {
513        self.cfg.popsize = v;
514        self
515    }
516    pub fn tol(mut self, v: f64) -> Self {
517        self.cfg.tol = v;
518        self
519    }
520    pub fn atol(mut self, v: f64) -> Self {
521        self.cfg.atol = v;
522        self
523    }
524    pub fn mutation(mut self, v: Mutation) -> Self {
525        self.cfg.mutation = v;
526        self
527    }
528    pub fn recombination(mut self, v: f64) -> Self {
529        self.cfg.recombination = v;
530        self
531    }
532    pub fn strategy(mut self, v: Strategy) -> Self {
533        self.cfg.strategy = v;
534        self
535    }
536    pub fn crossover(mut self, v: Crossover) -> Self {
537        self.cfg.crossover = v;
538        self
539    }
540    pub fn init(mut self, v: Init) -> Self {
541        self.cfg.init = v;
542        self
543    }
544    pub fn seed(mut self, v: u64) -> Self {
545        self.cfg.seed = Some(v);
546        self
547    }
548    pub fn integrality(mut self, v: Vec<bool>) -> Self {
549        self.cfg.integrality = Some(v);
550        self
551    }
552    pub fn x0(mut self, v: Array1<f64>) -> Self {
553        self.cfg.x0 = Some(v);
554        self
555    }
556    pub fn disp(mut self, v: bool) -> Self {
557        self.cfg.disp = v;
558        self
559    }
560    pub fn callback(mut self, cb: Box<dyn FnMut(&DEIntermediate) -> CallbackAction>) -> Self {
561        self.cfg.callback = Some(cb);
562        self
563    }
564    pub fn add_penalty_ineq<FN>(mut self, f: FN, w: f64) -> Self
565    where
566        FN: Fn(&Array1<f64>) -> f64 + Send + Sync + 'static,
567    {
568        self.cfg.penalty_ineq.push((Arc::new(f), w));
569        self
570    }
571    pub fn add_penalty_eq<FN>(mut self, f: FN, w: f64) -> Self
572    where
573        FN: Fn(&Array1<f64>) -> f64 + Send + Sync + 'static,
574    {
575        self.cfg.penalty_eq.push((Arc::new(f), w));
576        self
577    }
578    pub fn linear_penalty(mut self, lp: LinearPenalty) -> Self {
579        self.cfg.linear_penalty = Some(lp);
580        self
581    }
582    pub fn polish(mut self, pol: PolishConfig) -> Self {
583        self.cfg.polish = Some(pol);
584        self
585    }
586    pub fn adaptive(mut self, adaptive: AdaptiveConfig) -> Self {
587        self.cfg.adaptive = adaptive;
588        self
589    }
590    pub fn enable_adaptive_mutation(mut self, enable: bool) -> Self {
591        self.cfg.adaptive.adaptive_mutation = enable;
592        self
593    }
594    pub fn enable_wls(mut self, enable: bool) -> Self {
595        self.cfg.adaptive.wls_enabled = enable;
596        self
597    }
598    pub fn adaptive_weights(mut self, w_max: f64, w_min: f64) -> Self {
599        self.cfg.adaptive.w_max = w_max;
600        self.cfg.adaptive.w_min = w_min;
601        self
602    }
603    pub fn parallel(mut self, parallel: parallel_eval::ParallelConfig) -> Self {
604        self.cfg.parallel = parallel;
605        self
606    }
607    pub fn enable_parallel(mut self, enable: bool) -> Self {
608        self.cfg.parallel.enabled = enable;
609        self
610    }
611    pub fn parallel_threads(mut self, num_threads: usize) -> Self {
612        self.cfg.parallel.num_threads = Some(num_threads);
613        self
614    }
615    pub fn build(self) -> DEConfig {
616        self.cfg
617    }
618}
619
620/// Result/Report of a DE optimization run
621#[derive(Clone)]
622pub struct DEReport {
623    pub x: Array1<f64>,
624    pub fun: f64,
625    pub success: bool,
626    pub message: String,
627    pub nit: usize,
628    pub nfev: usize,
629    pub population: Array2<f64>,
630    pub population_energies: Array1<f64>,
631}
632
633impl fmt::Debug for DEReport {
634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635        f.debug_struct("DEReport")
636            .field("x", &format!("len={}", self.x.len()))
637            .field("fun", &self.fun)
638            .field("success", &self.success)
639            .field("message", &self.message)
640            .field("nit", &self.nit)
641            .field("nfev", &self.nfev)
642            .field(
643                "population",
644                &format!("{}x{}", self.population.nrows(), self.population.ncols()),
645            )
646            .field(
647                "population_energies",
648                &format!("len={}", self.population_energies.len()),
649            )
650            .finish()
651    }
652}
653
654/// Information passed to callback after each generation
655pub struct DEIntermediate {
656    pub x: Array1<f64>,
657    pub fun: f64,
658    pub convergence: f64, // measured as std(pop_f)
659    pub iter: usize,
660}
661
662/// Action returned by callback
663pub enum CallbackAction {
664    Continue,
665    Stop,
666}
667
668/// Differential Evolution optimizer
669pub struct DifferentialEvolution<'a, F>
670where
671    F: Fn(&Array1<f64>) -> f64 + Sync,
672{
673    func: &'a F,
674    lower: Array1<f64>,
675    upper: Array1<f64>,
676    config: DEConfig,
677}
678
679impl<'a, F> DifferentialEvolution<'a, F>
680where
681    F: Fn(&Array1<f64>) -> f64 + Sync,
682{
683    /// Create a new DE optimizer with objective `func` and bounds [lower, upper]
684    pub fn new(func: &'a F, lower: Array1<f64>, upper: Array1<f64>) -> Self {
685        assert_eq!(lower.len(), upper.len(), "lower/upper size mismatch");
686        Self {
687            func,
688            lower,
689            upper,
690            config: DEConfig::default(),
691        }
692    }
693
694    /// Mutable access to configuration
695    pub fn config_mut(&mut self) -> &mut DEConfig {
696        &mut self.config
697    }
698
699    /// Run the optimization and return a report
700    pub fn solve(&mut self) -> DEReport {
701        use apply_integrality::apply_integrality;
702        use apply_wls::apply_wls;
703        use crossover_binomial::binomial_crossover;
704        use crossover_exponential::exponential_crossover;
705        use init_latin_hypercube::init_latin_hypercube;
706        use init_random::init_random;
707        use mutant_adaptive::mutant_adaptive;
708        use mutant_best1::mutant_best1;
709        use mutant_best2::mutant_best2;
710        use mutant_current_to_best1::mutant_current_to_best1;
711        use mutant_rand_to_best1::mutant_rand_to_best1;
712        use mutant_rand1::mutant_rand1;
713        use mutant_rand2::mutant_rand2;
714        use parallel_eval::evaluate_trials_parallel;
715        use std::sync::Arc;
716
717        let n = self.lower.len();
718
719        // Identify fixed (equal-bounds) and free variables
720        let mut is_free: Vec<bool> = Vec::with_capacity(n);
721        for i in 0..n {
722            is_free.push((self.upper[i] - self.lower[i]).abs() > 0.0);
723        }
724        let n_free = is_free.iter().filter(|&&b| b).count();
725        let _n_equal = n - n_free;
726        if n_free == 0 {
727            // All fixed; just evaluate x = lower
728            let x_fixed = self.lower.clone();
729            let mut x_eval = x_fixed.clone();
730            if let Some(mask) = &self.config.integrality {
731                apply_integrality(&mut x_eval, mask, &self.lower, &self.upper);
732            }
733            let f = (self.func)(&x_eval);
734            return DEReport {
735                x: x_eval,
736                fun: f,
737                success: true,
738                message: "All variables fixed by bounds".into(),
739                nit: 0,
740                nfev: 1,
741                population: Array2::zeros((1, n)),
742                population_energies: Array1::from(vec![f]),
743            };
744        }
745
746        let npop = self.config.popsize * n_free;
747        let _bounds_span = &self.upper - &self.lower;
748
749        if self.config.disp {
750            eprintln!(
751                "DE Init: {} dimensions ({} free), population={}, maxiter={}",
752                n, n_free, npop, self.config.maxiter
753            );
754            eprintln!(
755                "  Strategy: {:?}, Mutation: {:?}, Crossover: CR={:.3}",
756                self.config.strategy, self.config.mutation, self.config.recombination
757            );
758            eprintln!(
759                "  Tolerances: tol={:.2e}, atol={:.2e}",
760                self.config.tol, self.config.atol
761            );
762        }
763
764        // Timing toggle via env var
765        let timing_enabled = std::env::var("AUTOEQ_DE_TIMING")
766            .map(|v| v != "0")
767            .unwrap_or(false);
768
769        // Configure global rayon thread pool once if requested
770        if let Some(n) = self.config.parallel.num_threads {
771            // Ignore error if global pool already set
772            let _ = rayon::ThreadPoolBuilder::new()
773                .num_threads(n)
774                .build_global();
775        }
776
777        // RNG
778        let mut rng: StdRng = match self.config.seed {
779            Some(s) => StdRng::seed_from_u64(s),
780            None => {
781                let mut thread_rng = rand::rng();
782                StdRng::from_rng(&mut thread_rng)
783            }
784        };
785
786        // Initialize population in [lower, upper]
787        let mut pop = match self.config.init {
788            Init::LatinHypercube => {
789                if self.config.disp {
790                    eprintln!("  Using Latin Hypercube initialization");
791                }
792                init_latin_hypercube(n, npop, &self.lower, &self.upper, &is_free, &mut rng)
793            }
794            Init::Random => {
795                if self.config.disp {
796                    eprintln!("  Using Random initialization");
797                }
798                init_random(n, npop, &self.lower, &self.upper, &is_free, &mut rng)
799            }
800        };
801
802        // Evaluate energies (objective + penalties)
803        let mut nfev: usize = 0;
804        if self.config.disp {
805            eprintln!("  Evaluating initial population of {} individuals...", npop);
806        }
807
808        // Prepare population for evaluation (apply integrality constraints)
809        let mut eval_pop = pop.clone();
810        let t_integrality0 = Instant::now();
811        if let Some(mask) = &self.config.integrality {
812            for i in 0..npop {
813                let mut row = eval_pop.row_mut(i);
814                let mut x_eval = row.to_owned();
815                apply_integrality(&mut x_eval, mask, &self.lower, &self.upper);
816                row.assign(&x_eval);
817            }
818        }
819        let t_integrality = t_integrality0.elapsed();
820
821        // Build thread-safe energy function that includes penalties
822        let func_ref = self.func;
823        let penalty_ineq_vec: Vec<PenaltyTuple> = self
824            .config
825            .penalty_ineq
826            .iter()
827            .map(|(f, w)| (f.clone(), *w))
828            .collect();
829        let penalty_eq_vec: Vec<PenaltyTuple> = self
830            .config
831            .penalty_eq
832            .iter()
833            .map(|(f, w)| (f.clone(), *w))
834            .collect();
835        let linear_penalty = self.config.linear_penalty.clone();
836
837        let energy_fn = Arc::new(move |x: &Array1<f64>| -> f64 {
838            let base = (func_ref)(x);
839            let mut p = 0.0;
840            for (f, w) in &penalty_ineq_vec {
841                let v = f(x);
842                let viol = v.max(0.0);
843                p += w * viol * viol;
844            }
845            for (h, w) in &penalty_eq_vec {
846                let v = h(x);
847                p += w * v * v;
848            }
849            if let Some(ref lp) = linear_penalty {
850                let ax = lp.a.dot(&x.view());
851                for i in 0..ax.len() {
852                    let v = ax[i];
853                    let lo = lp.lb[i];
854                    let hi = lp.ub[i];
855                    if v < lo {
856                        let d = lo - v;
857                        p += lp.weight * d * d;
858                    }
859                    if v > hi {
860                        let d = v - hi;
861                        p += lp.weight * d * d;
862                    }
863                }
864            }
865            base + p
866        });
867
868        let t_eval0 = Instant::now();
869        let mut energies = parallel_eval::evaluate_population_parallel(
870            &eval_pop,
871            energy_fn,
872            &self.config.parallel,
873        );
874        let t_eval_init = t_eval0.elapsed();
875        nfev += npop;
876        if timing_enabled {
877            eprintln!(
878                "TIMING init: integrality={:.3} ms, eval={:.3} ms",
879                t_integrality.as_secs_f64() * 1e3,
880                t_eval_init.as_secs_f64() * 1e3
881            );
882        }
883
884        // Report initial population statistics
885        let pop_mean = energies.mean().unwrap_or(0.0);
886        let pop_std = energies.std(0.0);
887        if self.config.disp {
888            eprintln!(
889                "  Initial population: mean={:.6e}, std={:.6e}",
890                pop_mean, pop_std
891            );
892        }
893
894        // If x0 provided, override the best member
895        if let Some(x0) = &self.config.x0 {
896            let mut x0c = x0.clone();
897            // Clip to bounds using ndarray
898            for i in 0..x0c.len() {
899                x0c[i] = x0c[i].clamp(self.lower[i], self.upper[i]);
900            }
901            if let Some(mask) = &self.config.integrality {
902                apply_integrality(&mut x0c, mask, &self.lower, &self.upper);
903            }
904            let f0 = self.energy(&x0c);
905            nfev += 1;
906            // find current best
907            let (best_idx, _best_f) = argmin(&energies);
908            pop.row_mut(best_idx).assign(&x0c.view());
909            energies[best_idx] = f0;
910        }
911
912        let (mut best_idx, mut best_f) = argmin(&energies);
913        let mut best_x = pop.row(best_idx).to_owned();
914
915        if self.config.disp {
916            eprintln!(
917                "  Initial best: fitness={:.6e} at index {}",
918                best_f, best_idx
919            );
920            let param_summary: Vec<String> = (0..best_x.len() / 3)
921                .map(|i| {
922                    let freq = 10f64.powf(best_x[i * 3]);
923                    let q = best_x[i * 3 + 1];
924                    let gain = best_x[i * 3 + 2];
925                    format!("f{:.0}Hz/Q{:.2}/G{:.2}dB", freq, q, gain)
926                })
927                .collect();
928            eprintln!("  Initial best params: [{}]", param_summary.join(", "));
929        }
930
931        if self.config.disp {
932            eprintln!("DE iter {:4}  best_f={:.6e}", 0, best_f);
933        }
934
935        // Initialize adaptive state if adaptive strategies are enabled
936        let mut adaptive_state = if matches!(
937            self.config.strategy,
938            Strategy::AdaptiveBin | Strategy::AdaptiveExp
939        ) || self.config.adaptive.adaptive_mutation
940        {
941            Some(AdaptiveState::new(&self.config.adaptive))
942        } else {
943            None
944        };
945
946        // Main loop
947        let mut success = false;
948        let mut message = String::new();
949        let mut nit = 0;
950        let mut accepted_trials;
951        let mut improvement_count;
952
953        let mut t_build_tot = std::time::Duration::ZERO;
954        let mut t_eval_tot = std::time::Duration::ZERO;
955        let mut t_select_tot = std::time::Duration::ZERO;
956        let mut t_iter_tot = std::time::Duration::ZERO;
957
958        for iter in 1..=self.config.maxiter {
959            nit = iter;
960            accepted_trials = 0;
961            improvement_count = 0;
962
963            let iter_start = Instant::now();
964
965            // Pre-sort indices for adaptive strategies to avoid re-sorting in the loop
966            let sorted_indices = if matches!(
967                self.config.strategy,
968                Strategy::AdaptiveBin | Strategy::AdaptiveExp
969            ) {
970                let mut indices: Vec<usize> = (0..npop).collect();
971                indices.sort_by(|&a, &b| {
972                    energies[a]
973                        .partial_cmp(&energies[b])
974                        .unwrap_or(std::cmp::Ordering::Equal)
975                });
976                indices
977            } else {
978                Vec::new() // Not needed for other strategies
979            };
980
981            // Generate all trials first, then evaluate in parallel
982            let t_build0 = Instant::now();
983
984            // Parallelize trial generation using rayon
985            use rayon::prelude::*;
986            let trial_data: Vec<(Array1<f64>, f64, f64)> = (0..npop)
987                .into_par_iter()
988                .map(|i| {
989                    // Create thread-local RNG from base seed + iteration + individual index
990                    let mut local_rng: StdRng = if let Some(base_seed) = self.config.seed {
991                        StdRng::seed_from_u64(
992                            base_seed
993                                .wrapping_add((iter as u64) << 32)
994                                .wrapping_add(i as u64),
995                        )
996                    } else {
997                        // Use thread_rng for unseeded runs
998                        let mut thread_rng = rand::rng();
999                        StdRng::from_rng(&mut thread_rng)
1000                    };
1001
1002                    // Sample mutation factor and crossover rate (adaptive or fixed)
1003                    let (f, cr) = if let Some(ref adaptive) = adaptive_state {
1004                        // Use adaptive parameter sampling
1005                        let adaptive_f = adaptive.sample_f(&mut local_rng);
1006                        let adaptive_cr = adaptive.sample_cr(&mut local_rng);
1007                        (adaptive_f, adaptive_cr)
1008                    } else {
1009                        // Use fixed or dithered parameters
1010                        (
1011                            self.config.mutation.sample(&mut local_rng),
1012                            self.config.recombination,
1013                        )
1014                    };
1015
1016                    // Generate mutant and apply crossover based on strategy
1017                    let (mutant, cross) = match self.config.strategy {
1018                        Strategy::Best1Bin => (
1019                            mutant_best1(i, &pop, best_idx, f, &mut local_rng),
1020                            Crossover::Binomial,
1021                        ),
1022                        Strategy::Best1Exp => (
1023                            mutant_best1(i, &pop, best_idx, f, &mut local_rng),
1024                            Crossover::Exponential,
1025                        ),
1026                        Strategy::Rand1Bin => (
1027                            mutant_rand1(i, &pop, f, &mut local_rng),
1028                            Crossover::Binomial,
1029                        ),
1030                        Strategy::Rand1Exp => (
1031                            mutant_rand1(i, &pop, f, &mut local_rng),
1032                            Crossover::Exponential,
1033                        ),
1034                        Strategy::Rand2Bin => (
1035                            mutant_rand2(i, &pop, f, &mut local_rng),
1036                            Crossover::Binomial,
1037                        ),
1038                        Strategy::Rand2Exp => (
1039                            mutant_rand2(i, &pop, f, &mut local_rng),
1040                            Crossover::Exponential,
1041                        ),
1042                        Strategy::CurrentToBest1Bin => (
1043                            mutant_current_to_best1(i, &pop, best_idx, f, &mut local_rng),
1044                            Crossover::Binomial,
1045                        ),
1046                        Strategy::CurrentToBest1Exp => (
1047                            mutant_current_to_best1(i, &pop, best_idx, f, &mut local_rng),
1048                            Crossover::Exponential,
1049                        ),
1050                        Strategy::Best2Bin => (
1051                            mutant_best2(i, &pop, best_idx, f, &mut local_rng),
1052                            Crossover::Binomial,
1053                        ),
1054                        Strategy::Best2Exp => (
1055                            mutant_best2(i, &pop, best_idx, f, &mut local_rng),
1056                            Crossover::Exponential,
1057                        ),
1058                        Strategy::RandToBest1Bin => (
1059                            mutant_rand_to_best1(i, &pop, best_idx, f, &mut local_rng),
1060                            Crossover::Binomial,
1061                        ),
1062                        Strategy::RandToBest1Exp => (
1063                            mutant_rand_to_best1(i, &pop, best_idx, f, &mut local_rng),
1064                            Crossover::Exponential,
1065                        ),
1066                        Strategy::AdaptiveBin => {
1067                            if let Some(ref adaptive) = adaptive_state {
1068                                (
1069                                    mutant_adaptive(
1070                                        i,
1071                                        &pop,
1072                                        &sorted_indices,
1073                                        adaptive.current_w,
1074                                        f,
1075                                        &mut local_rng,
1076                                    ),
1077                                    Crossover::Binomial,
1078                                )
1079                            } else {
1080                                // Fallback to rand1 if adaptive state not available
1081                                (
1082                                    mutant_rand1(i, &pop, f, &mut local_rng),
1083                                    Crossover::Binomial,
1084                                )
1085                            }
1086                        }
1087                        Strategy::AdaptiveExp => {
1088                            if let Some(ref adaptive) = adaptive_state {
1089                                (
1090                                    mutant_adaptive(
1091                                        i,
1092                                        &pop,
1093                                        &sorted_indices,
1094                                        adaptive.current_w,
1095                                        f,
1096                                        &mut local_rng,
1097                                    ),
1098                                    Crossover::Exponential,
1099                                )
1100                            } else {
1101                                // Fallback to rand1 if adaptive state not available
1102                                (
1103                                    mutant_rand1(i, &pop, f, &mut local_rng),
1104                                    Crossover::Exponential,
1105                                )
1106                            }
1107                        }
1108                    };
1109
1110                    // If strategy didn't dictate crossover, fallback to config
1111                    let crossover = cross;
1112                    let trial = match crossover {
1113                        Crossover::Binomial => {
1114                            binomial_crossover(&pop.row(i).to_owned(), &mutant, cr, &mut local_rng)
1115                        }
1116                        Crossover::Exponential => exponential_crossover(
1117                            &pop.row(i).to_owned(),
1118                            &mutant,
1119                            cr,
1120                            &mut local_rng,
1121                        ),
1122                    };
1123
1124                    // Apply WLS if enabled
1125                    let wls_trial = if self.config.adaptive.wls_enabled
1126                        && local_rng.random::<f64>() < self.config.adaptive.wls_prob
1127                    {
1128                        apply_wls(
1129                            &trial,
1130                            &self.lower,
1131                            &self.upper,
1132                            self.config.adaptive.wls_scale,
1133                            &mut local_rng,
1134                        )
1135                    } else {
1136                        trial.clone()
1137                    };
1138
1139                    // Clip to bounds using ndarray
1140                    let mut trial_clipped = wls_trial;
1141                    for j in 0..trial_clipped.len() {
1142                        trial_clipped[j] = trial_clipped[j].clamp(self.lower[j], self.upper[j]);
1143                    }
1144
1145                    // Apply integrality if provided
1146                    if let Some(mask) = &self.config.integrality {
1147                        apply_integrality(&mut trial_clipped, mask, &self.lower, &self.upper);
1148                    }
1149
1150                    // Return trial and parameters
1151                    (trial_clipped, f, cr)
1152                })
1153                .collect();
1154
1155            // Unpack trials and parameters
1156            let mut trials = Vec::with_capacity(npop);
1157            let mut trial_params = Vec::with_capacity(npop);
1158            for (trial, f, cr) in trial_data {
1159                trials.push(trial);
1160                trial_params.push((f, cr));
1161            }
1162            // Evaluate all trials including penalties, possibly in parallel
1163            let func_ref = self.func;
1164            let penalty_ineq_vec: Vec<PenaltyTuple> = self
1165                .config
1166                .penalty_ineq
1167                .iter()
1168                .map(|(f, w)| (f.clone(), *w))
1169                .collect();
1170            let penalty_eq_vec: Vec<PenaltyTuple> = self
1171                .config
1172                .penalty_eq
1173                .iter()
1174                .map(|(f, w)| (f.clone(), *w))
1175                .collect();
1176            let linear_penalty = self.config.linear_penalty.clone();
1177
1178            let energy_fn_loop = Arc::new(move |x: &Array1<f64>| -> f64 {
1179                let base = (func_ref)(x);
1180                let mut p = 0.0;
1181                for (f, w) in &penalty_ineq_vec {
1182                    let v = f(x);
1183                    let viol = v.max(0.0);
1184                    p += w * viol * viol;
1185                }
1186                for (h, w) in &penalty_eq_vec {
1187                    let v = h(x);
1188                    p += w * v * v;
1189                }
1190                if let Some(ref lp) = linear_penalty {
1191                    let ax = lp.a.dot(&x.view());
1192                    for i in 0..ax.len() {
1193                        let v = ax[i];
1194                        let lo = lp.lb[i];
1195                        let hi = lp.ub[i];
1196                        if v < lo {
1197                            let d = lo - v;
1198                            p += lp.weight * d * d;
1199                        }
1200                        if v > hi {
1201                            let d = v - hi;
1202                            p += lp.weight * d * d;
1203                        }
1204                    }
1205                }
1206                base + p
1207            });
1208
1209            let t_build = t_build0.elapsed();
1210            let t_eval0 = Instant::now();
1211            let trial_energies =
1212                evaluate_trials_parallel(trials.clone(), energy_fn_loop, &self.config.parallel);
1213            let t_eval = t_eval0.elapsed();
1214            nfev += npop;
1215
1216            let t_select0 = Instant::now();
1217            // Selection phase: update population based on trial results
1218            for (i, (trial, trial_energy)) in
1219                trials.into_iter().zip(trial_energies.iter()).enumerate()
1220            {
1221                let (f, cr) = trial_params[i];
1222
1223                // Selection: replace if better
1224                if *trial_energy <= energies[i] {
1225                    pop.row_mut(i).assign(&trial.view());
1226                    energies[i] = *trial_energy;
1227                    accepted_trials += 1;
1228
1229                    // Update adaptive parameters if improvement
1230                    if let Some(ref mut adaptive) = adaptive_state {
1231                        adaptive.record_success(f, cr);
1232                    }
1233
1234                    // Track if this is an improvement over the current best
1235                    if *trial_energy < best_f {
1236                        improvement_count += 1;
1237                    }
1238                }
1239            }
1240            let t_select = t_select0.elapsed();
1241
1242            t_build_tot += t_build;
1243            t_eval_tot += t_eval;
1244            t_select_tot += t_select;
1245            let iter_dur = iter_start.elapsed();
1246            t_iter_tot += iter_dur;
1247
1248            if timing_enabled && (iter <= 5 || iter % 10 == 0) {
1249                eprintln!(
1250                    "TIMING iter {:4}: build={:.3} ms, eval={:.3} ms, select={:.3} ms, total={:.3} ms",
1251                    iter,
1252                    t_build.as_secs_f64() * 1e3,
1253                    t_eval.as_secs_f64() * 1e3,
1254                    t_select.as_secs_f64() * 1e3,
1255                    iter_dur.as_secs_f64() * 1e3,
1256                );
1257            }
1258
1259            // Update adaptive parameters after each generation
1260            if let Some(ref mut adaptive) = adaptive_state {
1261                adaptive.update(&self.config.adaptive, iter, self.config.maxiter);
1262            }
1263
1264            // Update best solution after generation
1265            let (new_best_idx, new_best_f) = argmin(&energies);
1266            if new_best_f < best_f {
1267                best_idx = new_best_idx;
1268                best_f = new_best_f;
1269                best_x = pop.row(best_idx).to_owned();
1270            }
1271
1272            // Convergence check
1273            let pop_mean = energies.mean().unwrap_or(0.0);
1274            let pop_std = energies.std(0.0);
1275            let convergence_threshold = self.config.atol + self.config.tol * pop_mean.abs();
1276
1277            if self.config.disp {
1278                eprintln!(
1279                    "DE iter {:4}  best_f={:.6e}  std={:.3e}  accepted={}/{}, improved={}",
1280                    iter, best_f, pop_std, accepted_trials, npop, improvement_count
1281                );
1282            }
1283
1284            // Callback
1285            if let Some(ref mut cb) = self.config.callback {
1286                let intermediate = DEIntermediate {
1287                    x: best_x.clone(),
1288                    fun: best_f,
1289                    convergence: pop_std,
1290                    iter,
1291                };
1292                match cb(&intermediate) {
1293                    CallbackAction::Stop => {
1294                        success = true;
1295                        message = "Optimization stopped by callback".to_string();
1296                        break;
1297                    }
1298                    CallbackAction::Continue => {}
1299                }
1300            }
1301
1302            if pop_std <= convergence_threshold {
1303                success = true;
1304                message = format!(
1305                    "Converged: std(pop_f)={:.3e} <= threshold={:.3e}",
1306                    pop_std, convergence_threshold
1307                );
1308                break;
1309            }
1310        }
1311
1312        if !success {
1313            message = format!("Maximum iterations reached: {}", self.config.maxiter);
1314        }
1315
1316        if self.config.disp {
1317            eprintln!("DE finished: {}", message);
1318        }
1319
1320        // Polish if configured
1321        let (final_x, final_f, polish_nfev) = if let Some(ref polish_cfg) = self.config.polish {
1322            if polish_cfg.enabled {
1323                self.polish(&best_x)
1324            } else {
1325                (best_x.clone(), best_f, 0)
1326            }
1327        } else {
1328            (best_x.clone(), best_f, 0)
1329        };
1330
1331        if timing_enabled {
1332            eprintln!(
1333                "TIMING total: build={:.3} s, eval={:.3} s, select={:.3} s, iter_total={:.3} s",
1334                t_build_tot.as_secs_f64(),
1335                t_eval_tot.as_secs_f64(),
1336                t_select_tot.as_secs_f64(),
1337                t_iter_tot.as_secs_f64()
1338            );
1339        }
1340
1341        self.finish_report(
1342            pop,
1343            energies,
1344            final_x,
1345            final_f,
1346            success,
1347            message,
1348            nit,
1349            nfev + polish_nfev,
1350        )
1351    }
1352}
1353
1354#[cfg(test)]
1355mod strategy_tests {
1356    use super::*;
1357
1358    #[test]
1359    fn test_parse_strategy_variants() {
1360        assert!(matches!(
1361            "best1exp".parse::<Strategy>().unwrap(),
1362            Strategy::Best1Exp
1363        ));
1364        assert!(matches!(
1365            "rand1bin".parse::<Strategy>().unwrap(),
1366            Strategy::Rand1Bin
1367        ));
1368        assert!(matches!(
1369            "randtobest1exp".parse::<Strategy>().unwrap(),
1370            Strategy::RandToBest1Exp
1371        ));
1372    }
1373}