Skip to main content

fcmaes_core/
moretry.rs

1//! Parallel weighted-scalarization retry for multi-objective problems.
2//!
3//! This is the native counterpart of `fcmaes/moretry.py`. Every retry draws a
4//! different weight vector, normalizes it with the configured p-norm, maps it
5//! into the requested weight bounds, and runs an arbitrary scalar optimizer.
6//! Objective values and the sampled weights are retained with each result.
7//! As in [`mod@crate::retry`], workers own persistent independent PCG streams.
8
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
10use std::sync::{Mutex, MutexGuard};
11use std::time::Instant;
12
13use crate::fitness::{NAN_REPLACEMENT, Objective};
14use crate::retry::{
15    RetryBounds, RetryConfig, RetryContext, RetryImprovement, RetryRunResult, run_parallel,
16    spawned_worker_rng, worker_count,
17};
18use crate::rng::Rng;
19
20/// A synchronized vector-valued objective.
21pub trait MultiObjective: Sync {
22    fn eval(&self, x: &[f64]) -> Vec<f64>;
23}
24
25impl<F> MultiObjective for F
26where
27    F: Fn(&[f64]) -> Vec<f64> + Sync,
28{
29    fn eval(&self, x: &[f64]) -> Vec<f64> {
30        self(x)
31    }
32}
33
34/// Scalar view of a [`MultiObjective`] for one retry's sampled weights.
35pub struct WeightedObjective<'a, O: MultiObjective> {
36    objective: &'a O,
37    weights: &'a [f64],
38    ncon: usize,
39    value_exp: f64,
40}
41
42impl<'a, O: MultiObjective> WeightedObjective<'a, O> {
43    pub fn weights(&self) -> &[f64] {
44        self.weights
45    }
46
47    pub fn ncon(&self) -> usize {
48        self.ncon
49    }
50
51    pub fn value_exp(&self) -> f64 {
52        self.value_exp
53    }
54
55    /// Evaluate the original, unscalarized objective.
56    pub fn eval_multi(&self, x: &[f64]) -> Vec<f64> {
57        self.objective.eval(x)
58    }
59}
60
61impl<O: MultiObjective> Objective for WeightedObjective<'_, O> {
62    fn nobj(&self) -> usize {
63        1
64    }
65
66    fn eval(&self, x: &[f64]) -> Vec<f64> {
67        vec![self.eval_scalar(x)]
68    }
69
70    #[inline]
71    fn eval_scalar(&self, x: &[f64]) -> f64 {
72        scalarize(
73            &self.objective.eval(x),
74            self.weights,
75            self.ncon,
76            self.value_exp,
77        )
78    }
79}
80
81/// Apply the `moretry.py` p-norm scalarization and its positive-constraint
82/// penalty. Constraints are the final `ncon` values and are feasible at `<= 0`.
83pub fn scalarize(values: &[f64], weights: &[f64], ncon: usize, value_exp: f64) -> f64 {
84    if values.len() != weights.len()
85        || ncon >= values.len()
86        || !value_exp.is_finite()
87        || value_exp <= 0.0
88        || values.iter().any(|value| !value.is_finite())
89        || weights.iter().any(|weight| !weight.is_finite())
90    {
91        return NAN_REPLACEMENT;
92    }
93    let powered = values
94        .iter()
95        .zip(weights)
96        .map(|(&value, &weight)| (value * weight).powf(value_exp))
97        .sum::<f64>();
98    let mut scalar = powered.powf(value_exp.recip());
99    let nobj = values.len() - ncon;
100    for index in nobj..values.len() {
101        if values[index] > 0.0 {
102            scalar += weights[index];
103        }
104    }
105    if scalar.is_finite() {
106        scalar
107    } else {
108        NAN_REPLACEMENT
109    }
110}
111
112/// Multi-objective retry configuration.
113#[derive(Clone, Debug)]
114pub struct MoRetryConfig {
115    pub retry: RetryConfig,
116    pub weight_lower: Vec<f64>,
117    pub weight_upper: Vec<f64>,
118    pub ncon: usize,
119    pub value_exp: f64,
120    /// Optional strict upper bounds for every objective and constraint value.
121    pub value_limits: Option<Vec<f64>>,
122}
123
124impl MoRetryConfig {
125    pub fn new(weight_lower: Vec<f64>, weight_upper: Vec<f64>) -> Self {
126        Self {
127            retry: RetryConfig::default(),
128            weight_lower,
129            weight_upper,
130            ncon: 0,
131            value_exp: 2.0,
132            value_limits: None,
133        }
134    }
135
136    pub fn validate(&self) -> Result<(), &'static str> {
137        if self.weight_lower.is_empty() || self.weight_lower.len() != self.weight_upper.len() {
138            return Err("weight bounds must be non-empty and have equal lengths");
139        }
140        if self
141            .weight_lower
142            .iter()
143            .zip(&self.weight_upper)
144            .any(|(&lo, &hi)| !lo.is_finite() || !hi.is_finite() || lo > hi)
145        {
146            return Err("weight bounds must be finite and satisfy lower <= upper");
147        }
148        if self.ncon >= self.weight_lower.len() {
149            return Err("ncon must leave at least one objective");
150        }
151        if !self.value_exp.is_finite() || self.value_exp <= 0.0 {
152            return Err("value_exp must be finite and positive");
153        }
154        if self.value_limits.as_ref().is_some_and(|limits| {
155            limits.len() != self.weight_lower.len() || limits.iter().any(|limit| limit.is_nan())
156        }) {
157            return Err("value_limits must match the objective width and not contain NaN");
158        }
159        Ok(())
160    }
161}
162
163/// One retained weighted-scalarization result.
164#[derive(Clone, Debug, PartialEq)]
165pub struct MoRetryEntry {
166    pub x: Vec<f64>,
167    pub y: Vec<f64>,
168    pub weights: Vec<f64>,
169    pub scalar_value: f64,
170}
171
172/// Final result of [`moretry`].
173#[derive(Clone, Debug)]
174pub struct MoRetryResult {
175    pub x: Vec<f64>,
176    pub y: Vec<f64>,
177    pub scalar_value: f64,
178    pub evaluations: u64,
179    pub runs: usize,
180    pub success: bool,
181    pub entries: Vec<MoRetryEntry>,
182    pub improvements: Vec<RetryImprovement>,
183}
184
185struct MoStore {
186    dim: usize,
187    width: usize,
188    capacity: usize,
189    entries: Vec<MoRetryEntry>,
190    evaluations: u64,
191    runs: usize,
192    best_scalar: f64,
193    improvements: Vec<RetryImprovement>,
194    statistic_num: usize,
195    started: Instant,
196}
197
198impl MoStore {
199    fn new(dim: usize, width: usize, capacity: usize, statistic_num: usize) -> Self {
200        Self {
201            dim,
202            width,
203            capacity: capacity.max(1),
204            entries: Vec::with_capacity(capacity.max(1)),
205            evaluations: 0,
206            runs: 0,
207            best_scalar: f64::INFINITY,
208            improvements: Vec::with_capacity(statistic_num),
209            statistic_num,
210            started: Instant::now(),
211        }
212    }
213
214    fn add(
215        &mut self,
216        result: RetryRunResult,
217        values: Vec<f64>,
218        weights: Vec<f64>,
219        config: &MoRetryConfig,
220    ) {
221        self.runs += 1;
222        self.evaluations = self.evaluations.saturating_add(result.evaluations);
223        let within_limits = config.value_limits.as_ref().is_none_or(|limits| {
224            values
225                .iter()
226                .zip(limits)
227                .all(|(&value, &limit)| value < limit)
228        });
229        if result.x.len() != self.dim
230            || values.len() != self.width
231            || values.iter().any(|value| !value.is_finite())
232            || !result.y.is_finite()
233            || result.y >= config.retry.value_limit
234            || !within_limits
235        {
236            return;
237        }
238
239        if result.y < self.best_scalar {
240            self.best_scalar = result.y;
241            if self.statistic_num > 0 {
242                let sample = RetryImprovement {
243                    elapsed_seconds: self.started.elapsed().as_secs_f64(),
244                    evaluations: self.evaluations,
245                    value: result.y,
246                };
247                if self.improvements.len() == self.statistic_num {
248                    *self.improvements.last_mut().expect("non-empty statistics") = sample;
249                } else {
250                    self.improvements.push(sample);
251                }
252            }
253        }
254
255        if self.entries.len() >= self.capacity {
256            self.entries
257                .sort_unstable_by(|a, b| a.scalar_value.total_cmp(&b.scalar_value));
258            let keep = ((self.capacity as f64) * 0.9).floor() as usize;
259            self.entries
260                .truncate(keep.max(1).min(self.capacity.saturating_sub(1)));
261        }
262        self.entries.push(MoRetryEntry {
263            x: result.x,
264            y: values,
265            weights,
266            scalar_value: result.y,
267        });
268    }
269
270    fn into_result(mut self) -> MoRetryResult {
271        self.entries
272            .sort_unstable_by(|a, b| a.scalar_value.total_cmp(&b.scalar_value));
273        let (x, y, scalar_value) = self.entries.first().map_or_else(
274            || (Vec::new(), Vec::new(), f64::INFINITY),
275            |entry| (entry.x.clone(), entry.y.clone(), entry.scalar_value),
276        );
277        MoRetryResult {
278            x,
279            y,
280            scalar_value,
281            evaluations: self.evaluations,
282            runs: self.runs,
283            success: !self.entries.is_empty(),
284            entries: self.entries,
285            improvements: self.improvements,
286        }
287    }
288}
289
290fn lock_store(store: &Mutex<MoStore>) -> MutexGuard<'_, MoStore> {
291    store
292        .lock()
293        .unwrap_or_else(std::sync::PoisonError::into_inner)
294}
295
296fn sample_weights(config: &MoRetryConfig, rng: &mut Rng) -> Vec<f64> {
297    let mut raw: Vec<f64> = (0..config.weight_lower.len())
298        .map(|_| rng.uniform01())
299        .collect();
300    let mut norm = raw
301        .iter()
302        .map(|value| value.powf(config.value_exp))
303        .sum::<f64>()
304        .powf(config.value_exp.recip());
305    if !norm.is_finite() || norm == 0.0 {
306        raw.fill(0.0);
307        raw[0] = 1.0;
308        norm = 1.0;
309    }
310    raw.iter()
311        .zip(&config.weight_lower)
312        .zip(&config.weight_upper)
313        .map(|((&value, &lo), &hi)| lo + value / norm * (hi - lo))
314        .collect()
315}
316
317/// Run independent weighted-scalarization retries in parallel.
318pub fn moretry<O, F>(
319    objective: &O,
320    bounds: &RetryBounds,
321    config: &MoRetryConfig,
322    optimize: F,
323) -> Result<MoRetryResult, &'static str>
324where
325    O: MultiObjective,
326    F: for<'a> Fn(&WeightedObjective<'a, O>, &RetryContext) -> RetryRunResult + Sync + Send,
327{
328    config.validate()?;
329    let width = config.weight_lower.len();
330    if config.retry.num_retries == 0 {
331        return Ok(MoStore::new(
332            bounds.dim(),
333            width,
334            config.retry.capacity,
335            config.retry.statistic_num,
336        )
337        .into_result());
338    }
339
340    let workers = worker_count(config.retry.workers).min(config.retry.num_retries);
341    let next_run = AtomicUsize::new(0);
342    let stopped = AtomicBool::new(false);
343    let store = Mutex::new(MoStore::new(
344        bounds.dim(),
345        width,
346        config.retry.capacity,
347        config.retry.statistic_num,
348    ));
349
350    run_parallel(workers, |worker_id| {
351        let mut worker_rng = spawned_worker_rng(config.retry.seed, worker_id);
352        loop {
353            if stopped.load(AtomicOrdering::Relaxed) {
354                break;
355            }
356            let run_id = next_run.fetch_add(1, AtomicOrdering::Relaxed);
357            if run_id >= config.retry.num_retries {
358                break;
359            }
360            let weights = sample_weights(config, &mut worker_rng);
361            let sdev = vec![0.05 + 0.05 * worker_rng.uniform01(); bounds.dim()];
362            let context = RetryContext {
363                run_id,
364                seed: worker_rng.next_u64(),
365                bounds: bounds.clone(),
366                guess: None,
367                sdev,
368                max_evaluations: config.retry.max_evaluations,
369                value_limit: config.retry.value_limit,
370                crossover: false,
371            };
372            let weighted = WeightedObjective {
373                objective,
374                weights: &weights,
375                ncon: config.ncon,
376                value_exp: config.value_exp,
377            };
378            let result = optimize(&weighted, &context);
379            let values = if result.x.len() == bounds.dim() {
380                objective.eval(&result.x)
381            } else {
382                Vec::new()
383            };
384            let mut shared = lock_store(&store);
385            shared.add(result, values, weights, config);
386            if shared.best_scalar <= config.retry.stop_fitness {
387                stopped.store(true, AtomicOrdering::Relaxed);
388            }
389        }
390    });
391
392    Ok(store
393        .into_inner()
394        .unwrap_or_else(std::sync::PoisonError::into_inner)
395        .into_result())
396}
397
398/// Indices of non-dominated rows considering the first `nobj` values.
399pub fn pareto_indices(values: &[Vec<f64>], nobj: usize) -> Result<Vec<usize>, &'static str> {
400    if nobj == 0 {
401        return Err("nobj must be positive");
402    }
403    if values
404        .iter()
405        .any(|row| row.len() < nobj || row[..nobj].iter().any(|value| !value.is_finite()))
406    {
407        return Err("every value row must contain nobj finite values");
408    }
409    let mut front = Vec::new();
410    for candidate in 0..values.len() {
411        let dominated = (0..values.len()).any(|other| {
412            other != candidate
413                && (0..nobj).all(|j| values[other][j] <= values[candidate][j])
414                && (0..nobj).any(|j| values[other][j] < values[candidate][j])
415        });
416        if !dominated {
417            front.push(candidate);
418        }
419    }
420    front.sort_by(|&left, &right| values[left][0].total_cmp(&values[right][0]));
421    Ok(front)
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn bounds() -> RetryBounds {
429        RetryBounds::new(vec![-2.0, -2.0], vec![2.0, 2.0]).unwrap()
430    }
431
432    #[test]
433    fn scalarization_matches_python_formula_and_penalty() {
434        assert_eq!(scalarize(&[3.0, 4.0], &[1.0, 1.0], 0, 2.0), 5.0);
435        let penalized = scalarize(&[3.0, 4.0, 0.5], &[1.0, 1.0, 2.0], 1, 2.0);
436        assert!((penalized - (26.0_f64.sqrt() + 2.0)).abs() < 1e-12);
437        assert_eq!(scalarize(&[1.0], &[1.0, 2.0], 0, 2.0), NAN_REPLACEMENT);
438    }
439
440    #[test]
441    fn validates_configuration() {
442        assert!(
443            MoRetryConfig::new(Vec::new(), Vec::new())
444                .validate()
445                .is_err()
446        );
447        let mut config = MoRetryConfig::new(vec![0.0, 0.0], vec![1.0, 1.0]);
448        config.ncon = 2;
449        assert!(config.validate().is_err());
450        config.ncon = 0;
451        config.value_exp = 0.0;
452        assert!(config.validate().is_err());
453    }
454
455    #[test]
456    fn weighted_retry_is_deterministic_and_retains_vectors() {
457        let objective = |x: &[f64]| vec![x[0] * x[0], (x[1] - 1.0).powi(2)];
458        let mut config = MoRetryConfig::new(vec![0.5, 0.5], vec![1.5, 1.5]);
459        config.retry = RetryConfig {
460            num_retries: 12,
461            workers: 1,
462            capacity: 5,
463            seed: 123,
464            statistic_num: 3,
465            ..Default::default()
466        };
467        let run = |weighted: &WeightedObjective<'_, _>, context: &RetryContext| {
468            let mut rng = Rng::new(context.seed);
469            let x = vec![-2.0 + 4.0 * rng.uniform01(), -2.0 + 4.0 * rng.uniform01()];
470            RetryRunResult {
471                y: weighted.eval_scalar(&x),
472                x,
473                evaluations: 1,
474            }
475        };
476        let first = moretry(&objective, &bounds(), &config, run).unwrap();
477        let second = moretry(&objective, &bounds(), &config, run).unwrap();
478        assert_eq!(first.entries, second.entries);
479        assert_eq!(first.runs, 12);
480        assert_eq!(first.evaluations, 12);
481        assert!(first.success);
482        assert!(first.entries.len() <= 5);
483        assert!(first.entries.iter().all(|entry| entry.y.len() == 2));
484        assert!(first.improvements.len() <= 3);
485    }
486
487    #[test]
488    fn value_limits_filter_and_stop_works() {
489        let objective = |_: &[f64]| vec![0.0, 2.0];
490        let mut config = MoRetryConfig::new(vec![1.0, 1.0], vec![1.0, 1.0]);
491        config.value_limits = Some(vec![1.0, 1.0]);
492        config.retry.num_retries = 3;
493        config.retry.workers = 1;
494        let filtered = moretry(&objective, &bounds(), &config, |weighted, _| {
495            let x = vec![0.0, 0.0];
496            RetryRunResult {
497                y: weighted.eval_scalar(&x),
498                x,
499                evaluations: 1,
500            }
501        })
502        .unwrap();
503        assert!(!filtered.success);
504        assert_eq!(filtered.runs, 3);
505
506        config.value_limits = None;
507        config.retry.stop_fitness = 3.0;
508        config.retry.num_retries = 20;
509        let stopped = moretry(&objective, &bounds(), &config, |weighted, _| {
510            let x = vec![0.0, 0.0];
511            RetryRunResult {
512                y: weighted.eval_scalar(&x),
513                x,
514                evaluations: 1,
515            }
516        })
517        .unwrap();
518        assert_eq!(stopped.runs, 1);
519    }
520
521    #[test]
522    fn pareto_indices_handles_tradeoffs_duplicates_and_dominance() {
523        let values = vec![
524            vec![0.0, 2.0],
525            vec![1.0, 1.0],
526            vec![2.0, 0.0],
527            vec![2.0, 2.0],
528            vec![1.0, 1.0],
529        ];
530        assert_eq!(pareto_indices(&values, 2).unwrap(), vec![0, 1, 4, 2]);
531        assert!(pareto_indices(&values, 0).is_err());
532    }
533}