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