Skip to main content

fcmaes_core/
retry.rs

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