Skip to main content

fcmaes_core/
moretry.rs

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