Skip to main content

fcmaes_core/
retry.rs

1//! Parallel optimization restart coordinators.
2//!
3//! This is the native coordination core of the former `retry.py` and
4//! `advretry.py` implementations. A caller supplies one objective and a
5//! restart closure; the coordinator owns scheduling, independently spawned
6//! worker random streams, result retention, early stopping, and advanced
7//! crossover.
8
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
10use std::sync::{Arc, Mutex, MutexGuard};
11use std::time::Instant;
12
13use rayon::prelude::*;
14
15use crate::rng::Rng;
16
17/// Validated finite box bounds shared by all retries.
18#[derive(Clone, Debug, PartialEq)]
19pub struct RetryBounds {
20    lower: Arc<[f64]>,
21    upper: Arc<[f64]>,
22}
23
24impl RetryBounds {
25    /// Construct bounds, rejecting empty, mismatched, non-finite, or reversed
26    /// intervals.
27    pub fn new(lower: Vec<f64>, upper: Vec<f64>) -> Result<Self, &'static str> {
28        if lower.is_empty() || lower.len() != upper.len() {
29            return Err("bounds must be non-empty and have equal lengths");
30        }
31        if lower
32            .iter()
33            .zip(&upper)
34            .any(|(&lo, &hi)| !lo.is_finite() || !hi.is_finite() || lo >= hi)
35        {
36            return Err("bounds must contain finite intervals with lower < upper");
37        }
38        Ok(Self {
39            lower: lower.into(),
40            upper: upper.into(),
41        })
42    }
43
44    #[inline]
45    pub fn dim(&self) -> usize {
46        self.lower.len()
47    }
48
49    #[inline]
50    pub fn lower(&self) -> &[f64] {
51        &self.lower
52    }
53
54    #[inline]
55    pub fn upper(&self) -> &[f64] {
56        &self.upper
57    }
58}
59
60/// Inputs for one independent optimizer run.
61#[derive(Clone, Debug)]
62pub struct RetryContext {
63    pub run_id: usize,
64    pub seed: u64,
65    pub bounds: RetryBounds,
66    pub guess: Option<Vec<f64>>,
67    pub sdev: Vec<f64>,
68    pub max_evaluations: u64,
69    /// A crossover result is retained only when it improves this parent.
70    pub value_limit: f64,
71    pub crossover: bool,
72}
73
74/// Result returned by a caller-provided restart optimizer.
75#[derive(Clone, Debug, PartialEq)]
76pub struct RetryRunResult {
77    pub x: Vec<f64>,
78    pub y: f64,
79    pub evaluations: u64,
80}
81
82/// One retained retry result.
83#[derive(Clone, Debug, PartialEq)]
84pub struct RetryEntry {
85    pub x: Vec<f64>,
86    pub y: f64,
87}
88
89/// Progress sample registered whenever a completed retry improves the best
90/// retained objective value.
91#[derive(Clone, Debug, PartialEq)]
92pub struct RetryImprovement {
93    pub elapsed_seconds: f64,
94    pub evaluations: u64,
95    pub value: f64,
96}
97
98/// Final output common to basic and coordinated retry.
99#[derive(Clone, Debug)]
100pub struct RetryResult {
101    pub x: Vec<f64>,
102    pub y: f64,
103    pub evaluations: u64,
104    pub runs: usize,
105    pub success: bool,
106    pub entries: Vec<RetryEntry>,
107    pub improvements: Vec<RetryImprovement>,
108}
109
110/// Configuration corresponding to the scheduling and store controls in
111/// `retry.py`.
112#[derive(Clone, Debug)]
113pub struct RetryConfig {
114    pub num_retries: usize,
115    /// `0` uses all available CPUs.
116    pub workers: usize,
117    pub capacity: usize,
118    pub value_limit: f64,
119    pub stop_fitness: f64,
120    pub max_evaluations: u64,
121    pub seed: u64,
122    pub statistic_num: usize,
123}
124
125impl Default for RetryConfig {
126    fn default() -> Self {
127        Self {
128            num_retries: 1_024,
129            workers: 0,
130            capacity: 500,
131            value_limit: f64::INFINITY,
132            stop_fitness: f64::NEG_INFINITY,
133            max_evaluations: 50_000,
134            seed: 0,
135            statistic_num: 0,
136        }
137    }
138}
139
140/// Additional controls corresponding to `advretry.py`.
141#[derive(Clone, Debug)]
142pub struct AdvancedRetryConfig {
143    pub retry: RetryConfig,
144    pub check_interval: usize,
145    pub max_eval_fac: f64,
146    pub crossover_probability: f64,
147    pub diversity_threshold: f64,
148}
149
150impl Default for AdvancedRetryConfig {
151    fn default() -> Self {
152        Self {
153            retry: RetryConfig {
154                num_retries: 5_000,
155                max_evaluations: 1_500,
156                ..Default::default()
157            },
158            check_interval: 100,
159            max_eval_fac: 50.0,
160            crossover_probability: 0.5,
161            diversity_threshold: 0.15,
162        }
163    }
164}
165
166#[derive(Debug)]
167struct RetryStore {
168    dim: usize,
169    capacity: usize,
170    entries: Vec<RetryEntry>,
171    best_x: Vec<f64>,
172    best_y: f64,
173    evaluations: u64,
174    completed_runs: usize,
175    improvements: Vec<RetryImprovement>,
176    statistic_num: usize,
177    started: Instant,
178}
179
180impl RetryStore {
181    fn new(dim: usize, capacity: usize, statistic_num: usize) -> Self {
182        Self {
183            dim,
184            capacity: capacity.max(1),
185            entries: Vec::with_capacity(capacity.max(1)),
186            best_x: vec![0.0; dim],
187            best_y: f64::INFINITY,
188            evaluations: 0,
189            completed_runs: 0,
190            improvements: Vec::with_capacity(statistic_num),
191            statistic_num,
192            started: Instant::now(),
193        }
194    }
195
196    fn add(&mut self, result: RetryRunResult, limit: f64) -> bool {
197        self.completed_runs += 1;
198        self.evaluations = self.evaluations.saturating_add(result.evaluations);
199        if result.x.len() != self.dim || !result.y.is_finite() || result.y >= limit {
200            return false;
201        }
202
203        let improved = result.y < self.best_y;
204        if improved {
205            self.best_y = result.y;
206            self.best_x.clone_from(&result.x);
207            if self.statistic_num > 0 {
208                let sample = RetryImprovement {
209                    elapsed_seconds: self.started.elapsed().as_secs_f64(),
210                    evaluations: self.evaluations,
211                    value: result.y,
212                };
213                if self.improvements.len() == self.statistic_num {
214                    if let Some(last) = self.improvements.last_mut() {
215                        *last = sample;
216                    }
217                } else {
218                    self.improvements.push(sample);
219                }
220            }
221        }
222
223        if self.entries.len() >= self.capacity {
224            self.sort_basic();
225            if self.entries.len() >= self.capacity {
226                self.entries.pop();
227            }
228        }
229        self.entries.push(RetryEntry {
230            x: result.x,
231            y: result.y,
232        });
233        improved
234    }
235
236    fn sort_basic(&mut self) {
237        self.entries.sort_unstable_by(|a, b| a.y.total_cmp(&b.y));
238        let keep = ((self.capacity as f64) * 0.9).floor() as usize;
239        self.entries.truncate(keep.max(1).min(self.capacity));
240    }
241
242    #[cfg(test)]
243    fn normalized_distance(&self, a: &[f64], b: &[f64], bounds: &RetryBounds) -> f64 {
244        let squared = a
245            .iter()
246            .zip(b)
247            .zip(bounds.lower().iter().zip(bounds.upper()))
248            .map(|((&av, &bv), (&lo, &hi))| ((av - bv) / (hi - lo)).powi(2))
249            .sum::<f64>();
250        (squared / self.dim as f64).sqrt()
251    }
252
253    fn sort_diverse(&mut self, bounds: &RetryBounds, threshold: f64) {
254        self.entries.sort_unstable_by(|a, b| a.y.total_cmp(&b.y));
255        let mut diverse = Vec::with_capacity(self.entries.len());
256        for entry in self.entries.drain(..) {
257            let sufficiently_different =
258                diverse.iter().rev().take(2).all(|previous: &RetryEntry| {
259                    let squared = previous
260                        .x
261                        .iter()
262                        .zip(&entry.x)
263                        .zip(bounds.lower().iter().zip(bounds.upper()))
264                        .map(|((&a, &b), (&lo, &hi))| ((a - b) / (hi - lo)).powi(2))
265                        .sum::<f64>();
266                    (squared / self.dim as f64).sqrt() > threshold
267                });
268            if sufficiently_different {
269                diverse.push(entry);
270            }
271        }
272        let keep = ((self.capacity as f64) * 0.9).floor() as usize;
273        diverse.truncate(keep.max(1).min(self.capacity));
274        self.entries = diverse;
275    }
276
277    fn into_result(mut self) -> RetryResult {
278        self.entries.sort_unstable_by(|a, b| a.y.total_cmp(&b.y));
279        RetryResult {
280            x: self.best_x,
281            y: self.best_y,
282            evaluations: self.evaluations,
283            runs: self.completed_runs,
284            success: self.best_y.is_finite(),
285            entries: self.entries,
286            improvements: self.improvements,
287        }
288    }
289}
290
291fn lock_store(store: &Mutex<RetryStore>) -> MutexGuard<'_, RetryStore> {
292    store
293        .lock()
294        .unwrap_or_else(std::sync::PoisonError::into_inner)
295}
296
297pub(crate) fn worker_count(requested: usize) -> usize {
298    if requested > 0 {
299        requested
300    } else {
301        std::thread::available_parallelism().map_or(1, usize::from)
302    }
303}
304
305/// SplitMix64 finalizer used to initialize spawned worker streams.
306#[inline]
307fn splitmix64(mut z: u64) -> u64 {
308    z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
309    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
310    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
311    z ^ (z >> 31)
312}
313
314/// Spawn one persistent PCG stream per retry worker. Distinct PCG stream
315/// selectors provide the same independence property sought by NumPy's
316/// `SeedSequence.spawn(workers)` without sharing mutable RNG state.
317pub(crate) fn spawned_worker_rng(root_seed: u64, worker_id: usize) -> Rng {
318    let worker = worker_id as u64;
319    let state = ((splitmix64(root_seed ^ 0xD2B7_4407_B1CE_6E93 ^ worker) as u128) << 64)
320        | splitmix64(root_seed ^ 0xCA5A_8263_9512_1157 ^ worker) as u128;
321    // The low half makes stream selectors unique for every worker. The high
322    // half separates equal worker indices under different root seeds.
323    let stream = ((splitmix64(root_seed ^ 0x9E37_79B9_7F4A_7C15) as u128) << 64) | worker as u128;
324    Rng::from_state_stream(state, stream)
325}
326
327fn initial_sdev(dim: usize, rng: &mut Rng) -> Vec<f64> {
328    let value = 0.05 + 0.05 * rng.uniform01();
329    vec![value; dim]
330}
331
332pub(crate) fn run_parallel<F>(workers: usize, task: F)
333where
334    F: Fn(usize) + Sync + Send,
335{
336    rayon::ThreadPoolBuilder::new()
337        .num_threads(workers)
338        .thread_name(|index| format!("fcmaes-retry-{index}"))
339        .build()
340        .expect("failed to build retry worker pool")
341        .install(|| {
342            (0..workers).into_par_iter().for_each(task);
343        });
344}
345
346/// Run independent restarts in parallel. The optimizer closure is invoked
347/// exactly once for every claimed run unless another worker already reached
348/// `stop_fitness`.
349pub fn retry<O, F>(
350    objective: &O,
351    bounds: &RetryBounds,
352    config: &RetryConfig,
353    optimize: F,
354) -> RetryResult
355where
356    O: Fn(&[f64]) -> f64 + Sync,
357    F: Fn(&O, &RetryContext) -> RetryRunResult + Sync + Send,
358{
359    if config.num_retries == 0 {
360        return RetryStore::new(bounds.dim(), config.capacity, config.statistic_num).into_result();
361    }
362    let workers = worker_count(config.workers).min(config.num_retries);
363    let next_run = AtomicUsize::new(0);
364    let stopped = AtomicBool::new(false);
365    let store = Mutex::new(RetryStore::new(
366        bounds.dim(),
367        config.capacity,
368        config.statistic_num,
369    ));
370
371    run_parallel(workers, |worker_id| {
372        let mut worker_rng = spawned_worker_rng(config.seed, worker_id);
373        loop {
374            if stopped.load(AtomicOrdering::Relaxed) {
375                break;
376            }
377            let run_id = next_run.fetch_add(1, AtomicOrdering::Relaxed);
378            if run_id >= config.num_retries {
379                break;
380            }
381            let sdev = initial_sdev(bounds.dim(), &mut worker_rng);
382            let context = RetryContext {
383                run_id,
384                seed: worker_rng.next_u64(),
385                bounds: bounds.clone(),
386                guess: None,
387                sdev,
388                max_evaluations: config.max_evaluations,
389                value_limit: config.value_limit,
390                crossover: false,
391            };
392            let result = optimize(objective, &context);
393            let mut shared = lock_store(&store);
394            shared.add(result, config.value_limit);
395            if shared.best_y <= config.stop_fitness {
396                stopped.store(true, AtomicOrdering::Relaxed);
397            }
398        }
399    });
400
401    store
402        .into_inner()
403        .unwrap_or_else(std::sync::PoisonError::into_inner)
404        .into_result()
405}
406
407fn advanced_context(
408    run_id: usize,
409    bounds: &RetryBounds,
410    config: &AdvancedRetryConfig,
411    store: &mut RetryStore,
412    worker_rng: &mut Rng,
413) -> RetryContext {
414    if config.check_interval > 0 && run_id > 0 && run_id.is_multiple_of(config.check_interval) {
415        store.sort_diverse(bounds, config.diversity_threshold.max(0.0));
416    }
417
418    let progress = if config.retry.num_retries <= 1 {
419        1.0
420    } else {
421        run_id as f64 / (config.retry.num_retries - 1) as f64
422    };
423    let factor = 1.0 + (config.max_eval_fac.max(1.0) - 1.0) * progress;
424    let max_evaluations = ((config.retry.max_evaluations as f64) * factor)
425        .round()
426        .clamp(1.0, u64::MAX as f64) as u64;
427
428    let try_crossover = worker_rng.uniform01() < config.crossover_probability.clamp(0.0, 1.0);
429    let use_crossover = store.entries.len() >= 2 && try_crossover;
430    if !use_crossover {
431        let sdev = initial_sdev(bounds.dim(), worker_rng);
432        return RetryContext {
433            run_id,
434            seed: worker_rng.next_u64(),
435            bounds: bounds.clone(),
436            guess: None,
437            sdev,
438            max_evaluations,
439            value_limit: config.retry.value_limit,
440            crossover: false,
441        };
442    }
443
444    // The store is sorted at every checkpoint and on capacity pressure. Bias
445    // both parents toward its best 20%, while keeping them distinct.
446    let elite = ((store.entries.len() as f64 * 0.2).ceil() as usize)
447        .max(2)
448        .min(store.entries.len());
449    let first = ((worker_rng.uniform01().powi(2) * elite as f64) as usize).min(elite - 1);
450    let mut second = ((worker_rng.uniform01().powi(2) * elite as f64) as usize).min(elite - 1);
451    if first == second {
452        second = (second + 1) % elite;
453    }
454    let parent = &store.entries[first];
455    let donor = &store.entries[second];
456    let diff_fac = 0.5 + 0.5 * worker_rng.uniform01();
457    let limit_fac = (2.0 + 2.0 * worker_rng.uniform01()) * diff_fac;
458    let mut lower = Vec::with_capacity(bounds.dim());
459    let mut upper = Vec::with_capacity(bounds.dim());
460    let mut guess = Vec::with_capacity(bounds.dim());
461    let mut sdev = Vec::with_capacity(bounds.dim());
462    for i in 0..bounds.dim() {
463        let global_delta = bounds.upper()[i] - bounds.lower()[i];
464        let delta = (donor.x[i] - parent.x[i]).abs();
465        let local_delta = (limit_fac * delta).max(0.0001);
466        let lo = bounds.lower()[i].max(parent.x[i] - local_delta);
467        let hi = bounds.upper()[i].min(parent.x[i] + local_delta);
468        lower.push(lo);
469        upper.push(hi);
470        guess.push(donor.x[i].clamp(lo, hi));
471        sdev.push((diff_fac * delta / global_delta).clamp(0.001, 0.5));
472    }
473
474    RetryContext {
475        run_id,
476        seed: worker_rng.next_u64(),
477        bounds: RetryBounds::new(lower, upper).expect("crossover bounds are valid"),
478        guess: Some(guess),
479        sdev,
480        max_evaluations,
481        value_limit: parent.y.min(config.retry.value_limit),
482        crossover: true,
483    }
484}
485
486/// Run adaptive-budget retries with coordinated crossover guesses and
487/// diversity-preserving result retention.
488pub fn advanced_retry<O, F>(
489    objective: &O,
490    bounds: &RetryBounds,
491    config: &AdvancedRetryConfig,
492    optimize: F,
493) -> RetryResult
494where
495    O: Fn(&[f64]) -> f64 + Sync,
496    F: Fn(&O, &RetryContext) -> RetryRunResult + Sync + Send,
497{
498    if config.retry.num_retries == 0 {
499        return RetryStore::new(
500            bounds.dim(),
501            config.retry.capacity,
502            config.retry.statistic_num,
503        )
504        .into_result();
505    }
506    let workers = worker_count(config.retry.workers).min(config.retry.num_retries);
507    let next_run = AtomicUsize::new(0);
508    let stopped = AtomicBool::new(false);
509    let store = Mutex::new(RetryStore::new(
510        bounds.dim(),
511        config.retry.capacity,
512        config.retry.statistic_num,
513    ));
514
515    run_parallel(workers, |worker_id| {
516        let mut worker_rng = spawned_worker_rng(config.retry.seed, worker_id);
517        loop {
518            if stopped.load(AtomicOrdering::Relaxed) {
519                break;
520            }
521            let run_id = next_run.fetch_add(1, AtomicOrdering::Relaxed);
522            if run_id >= config.retry.num_retries {
523                break;
524            }
525            let context = {
526                let mut shared = lock_store(&store);
527                advanced_context(run_id, bounds, config, &mut shared, &mut worker_rng)
528            };
529            let limit = context.value_limit;
530            let result = optimize(objective, &context);
531            let mut shared = lock_store(&store);
532            shared.add(result, limit);
533            if shared.best_y <= config.retry.stop_fitness {
534                stopped.store(true, AtomicOrdering::Relaxed);
535            }
536        }
537    });
538
539    let mut store = store
540        .into_inner()
541        .unwrap_or_else(std::sync::PoisonError::into_inner);
542    store.sort_diverse(bounds, config.diversity_threshold.max(0.0));
543    store.into_result()
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    fn bounds() -> RetryBounds {
551        RetryBounds::new(vec![-5.0, -5.0], vec![5.0, 5.0]).unwrap()
552    }
553
554    fn sample_run<O: Fn(&[f64]) -> f64>(objective: &O, context: &RetryContext) -> RetryRunResult {
555        let mut rng = Rng::new(context.seed);
556        let x: Vec<f64> = (0..context.bounds.dim())
557            .map(|i| {
558                context.bounds.lower()[i]
559                    + rng.uniform01() * (context.bounds.upper()[i] - context.bounds.lower()[i])
560            })
561            .collect();
562        RetryRunResult {
563            y: objective(&x),
564            x,
565            evaluations: 1,
566        }
567    }
568
569    #[test]
570    fn rejects_invalid_bounds() {
571        assert!(RetryBounds::new(vec![], vec![]).is_err());
572        assert!(RetryBounds::new(vec![0.0], vec![1.0, 2.0]).is_err());
573        assert!(RetryBounds::new(vec![1.0], vec![1.0]).is_err());
574        assert!(RetryBounds::new(vec![f64::NAN], vec![1.0]).is_err());
575    }
576
577    #[test]
578    fn single_worker_retry_is_deterministic_and_counts() {
579        let config = RetryConfig {
580            num_retries: 40,
581            workers: 1,
582            capacity: 8,
583            seed: 123,
584            statistic_num: 3,
585            ..Default::default()
586        };
587        let objective = |x: &[f64]| x.iter().map(|v| v * v).sum();
588        let first = retry(&objective, &bounds(), &config, sample_run);
589        let second = retry(&objective, &bounds(), &config, sample_run);
590        assert!(first.success);
591        assert_eq!(first.y, second.y);
592        assert_eq!(first.x, second.x);
593        assert_eq!(first.runs, 40);
594        assert_eq!(first.evaluations, 40);
595        assert!(first.entries.len() <= config.capacity);
596        assert!(first.improvements.len() <= 3);
597    }
598
599    #[test]
600    fn spawned_worker_streams_are_independent_and_reproducible() {
601        let sample = |root_seed| {
602            (0..8)
603                .map(|worker_id| {
604                    let mut rng = spawned_worker_rng(root_seed, worker_id);
605                    (0..16).map(|_| rng.next_u64()).collect::<Vec<_>>()
606                })
607                .collect::<Vec<_>>()
608        };
609        let first = sample(123);
610        assert_eq!(first, sample(123));
611        assert_ne!(first, sample(124));
612        for left in 0..first.len() {
613            for right in left + 1..first.len() {
614                assert_ne!(first[left], first[right]);
615            }
616        }
617    }
618
619    #[test]
620    fn basic_retry_draws_contexts_from_the_persistent_worker_stream() {
621        let observed = Mutex::new(Vec::new());
622        let config = RetryConfig {
623            num_retries: 3,
624            workers: 1,
625            seed: 321,
626            ..Default::default()
627        };
628        retry(&|_: &[f64]| 0.0, &bounds(), &config, |_, context| {
629            observed
630                .lock()
631                .unwrap()
632                .push((context.sdev[0], context.seed));
633            RetryRunResult {
634                x: vec![0.0; context.bounds.dim()],
635                y: 0.0,
636                evaluations: 1,
637            }
638        });
639
640        let mut worker_rng = spawned_worker_rng(config.seed, 0);
641        let expected: Vec<(f64, u64)> = (0..config.num_retries)
642            .map(|_| {
643                let sdev = initial_sdev(bounds().dim(), &mut worker_rng)[0];
644                (sdev, worker_rng.next_u64())
645            })
646            .collect();
647        assert_eq!(observed.into_inner().unwrap(), expected);
648    }
649
650    #[test]
651    fn advanced_retry_draws_contexts_from_the_persistent_worker_stream() {
652        let observed = Mutex::new(Vec::new());
653        let config = AdvancedRetryConfig {
654            retry: RetryConfig {
655                num_retries: 3,
656                workers: 1,
657                seed: 654,
658                ..Default::default()
659            },
660            crossover_probability: 0.0,
661            ..Default::default()
662        };
663        advanced_retry(&|_: &[f64]| 0.0, &bounds(), &config, |_, context| {
664            observed
665                .lock()
666                .unwrap()
667                .push((context.sdev[0], context.seed));
668            RetryRunResult {
669                x: vec![0.0; context.bounds.dim()],
670                y: 0.0,
671                evaluations: 1,
672            }
673        });
674
675        let mut worker_rng = spawned_worker_rng(config.retry.seed, 0);
676        let expected: Vec<(f64, u64)> = (0..config.retry.num_retries)
677            .map(|_| {
678                let _crossover_draw = worker_rng.uniform01();
679                let sdev = initial_sdev(bounds().dim(), &mut worker_rng)[0];
680                (sdev, worker_rng.next_u64())
681            })
682            .collect();
683        assert_eq!(observed.into_inner().unwrap(), expected);
684    }
685
686    #[test]
687    fn retry_filters_bad_results_and_empty_runs() {
688        let empty = retry(
689            &|_: &[f64]| 0.0,
690            &bounds(),
691            &RetryConfig {
692                num_retries: 0,
693                ..Default::default()
694            },
695            sample_run,
696        );
697        assert!(!empty.success);
698        assert!(empty.y.is_infinite());
699
700        let filtered = retry(
701            &|_: &[f64]| 2.0,
702            &bounds(),
703            &RetryConfig {
704                num_retries: 3,
705                workers: 1,
706                value_limit: 1.0,
707                ..Default::default()
708            },
709            sample_run,
710        );
711        assert!(!filtered.success);
712        assert!(filtered.entries.is_empty());
713        assert_eq!(filtered.runs, 3);
714    }
715
716    #[test]
717    fn stop_fitness_stops_early() {
718        let result = retry(
719            &|_: &[f64]| -1.0,
720            &bounds(),
721            &RetryConfig {
722                num_retries: 100,
723                workers: 1,
724                stop_fitness: 0.0,
725                ..Default::default()
726            },
727            sample_run,
728        );
729        assert_eq!(result.runs, 1);
730        assert_eq!(result.y, -1.0);
731    }
732
733    #[test]
734    fn advanced_retry_increases_budget_and_crosses_over() {
735        let contexts = Mutex::new(Vec::new());
736        let config = AdvancedRetryConfig {
737            retry: RetryConfig {
738                num_retries: 12,
739                workers: 1,
740                capacity: 10,
741                max_evaluations: 100,
742                seed: 99,
743                ..Default::default()
744            },
745            check_interval: 2,
746            max_eval_fac: 4.0,
747            crossover_probability: 1.0,
748            diversity_threshold: 0.0,
749        };
750        let result = advanced_retry(&|x: &[f64]| x[0], &bounds(), &config, |objective, ctx| {
751            contexts.lock().unwrap().push(ctx.clone());
752            sample_run(objective, ctx)
753        });
754        let contexts = contexts.into_inner().unwrap();
755        assert_eq!(contexts.first().unwrap().max_evaluations, 100);
756        assert_eq!(contexts.last().unwrap().max_evaluations, 400);
757        assert!(contexts.iter().skip(2).any(|context| context.crossover));
758        assert!(
759            contexts
760                .iter()
761                .filter(|context| context.crossover)
762                .all(|context| context.guess.is_some())
763        );
764        assert!(result.success);
765    }
766
767    #[test]
768    fn advanced_retry_handles_single_run_and_filters_dimension_mismatch() {
769        let config = AdvancedRetryConfig {
770            retry: RetryConfig {
771                num_retries: 1,
772                workers: 0,
773                max_evaluations: 7,
774                ..Default::default()
775            },
776            check_interval: 0,
777            max_eval_fac: 3.0,
778            ..Default::default()
779        };
780        let result = advanced_retry(&|_: &[f64]| 0.0, &bounds(), &config, |_, context| {
781            assert_eq!(context.max_evaluations, 21);
782            RetryRunResult {
783                x: vec![0.0],
784                y: 0.0,
785                evaluations: 5,
786            }
787        });
788        assert!(!result.success);
789        assert_eq!(result.runs, 1);
790        assert_eq!(result.evaluations, 5);
791
792        let empty = advanced_retry(
793            &|_: &[f64]| 0.0,
794            &bounds(),
795            &AdvancedRetryConfig {
796                retry: RetryConfig {
797                    num_retries: 0,
798                    ..Default::default()
799                },
800                ..Default::default()
801            },
802            sample_run,
803        );
804        assert!(!empty.success);
805        assert_eq!(empty.runs, 0);
806    }
807
808    #[test]
809    fn store_diversity_and_distance() {
810        let bounds = bounds();
811        let mut store = RetryStore::new(2, 10, 0);
812        for (x, y) in [
813            (vec![0.0, 0.0], 0.0),
814            (vec![0.01, 0.01], 1.0),
815            (vec![4.0, 4.0], 2.0),
816        ] {
817            store.add(
818                RetryRunResult {
819                    x,
820                    y,
821                    evaluations: 1,
822                },
823                f64::INFINITY,
824            );
825        }
826        assert!(store.normalized_distance(&[0.0, 0.0], &[5.0, 5.0], &bounds) > 0.0);
827        store.sort_diverse(&bounds, 0.15);
828        assert_eq!(store.entries.len(), 2);
829        assert_eq!(store.entries[0].y, 0.0);
830
831        let mut tiny = RetryStore::new(2, 1, 0);
832        for value in [3.0, 2.0, 1.0] {
833            tiny.add(
834                RetryRunResult {
835                    x: vec![value; 2],
836                    y: value,
837                    evaluations: 1,
838                },
839                f64::INFINITY,
840            );
841        }
842        assert_eq!(tiny.entries.len(), 1);
843        assert_eq!(tiny.best_y, 1.0);
844    }
845
846    #[test]
847    fn advanced_stop_fitness_stops_early() {
848        let result = advanced_retry(
849            &|_: &[f64]| -2.0,
850            &bounds(),
851            &AdvancedRetryConfig {
852                retry: RetryConfig {
853                    num_retries: 20,
854                    workers: 1,
855                    stop_fitness: -1.0,
856                    ..Default::default()
857                },
858                ..Default::default()
859            },
860            sample_run,
861        );
862        assert_eq!(result.runs, 1);
863        assert_eq!(result.y, -2.0);
864    }
865}