1use 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
20pub 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
34pub 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 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
81pub 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#[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 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#[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#[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
317pub 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
398pub 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}