1use 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
30pub trait MultiObjective: Sync {
32 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
45pub 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 pub fn weights(&self) -> &[f64] {
56 self.weights
57 }
58
59 pub fn ncon(&self) -> usize {
61 self.ncon
62 }
63
64 pub fn value_exp(&self) -> f64 {
66 self.value_exp
67 }
68
69 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
95pub 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#[derive(Clone, Debug)]
129pub struct MoRetryConfig {
130 pub retry: RetryConfig,
132 pub weight_lower: Vec<f64>,
134 pub weight_upper: Vec<f64>,
136 pub ncon: usize,
138 pub value_exp: f64,
140 pub value_limits: Option<Vec<f64>>,
142}
143
144impl MoRetryConfig {
145 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 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#[derive(Clone, Debug, PartialEq)]
194pub struct MoRetryEntry {
195 pub x: Vec<f64>,
197 pub y: Vec<f64>,
199 pub weights: Vec<f64>,
201 pub scalar_value: f64,
203}
204
205#[derive(Clone, Debug)]
207pub struct MoRetryResult {
208 pub x: Vec<f64>,
210 pub y: Vec<f64>,
212 pub scalar_value: f64,
214 pub evaluations: u64,
216 pub runs: usize,
218 pub success: bool,
220 pub entries: Vec<MoRetryEntry>,
222 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
358pub 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 seed: worker_rng.next_u64(),
410 bounds: bounds.clone(),
411 guess: None,
412 sdev,
413 max_evaluations: config.retry.max_evaluations,
414 value_limit: config.retry.value_limit,
415 crossover: false,
416 };
417 let weighted = WeightedObjective {
418 objective,
419 weights: &weights,
420 ncon: config.ncon,
421 value_exp: config.value_exp,
422 };
423 let result = optimize(&weighted, &context);
424 let values = if result.x.len() == bounds.dim() {
425 objective.eval(&result.x)
426 } else {
427 Vec::new()
428 };
429 let mut shared = lock_store(&store);
430 shared.add(result, values, weights, config);
431 if shared.best_scalar <= config.retry.stop_fitness {
432 stopped.store(true, AtomicOrdering::Relaxed);
433 }
434 }
435 });
436
437 Ok(store
438 .into_inner()
439 .unwrap_or_else(std::sync::PoisonError::into_inner)
440 .into_result())
441}
442
443pub fn pareto_indices(values: &[Vec<f64>], nobj: usize) -> Result<Vec<usize>, &'static str> {
450 if nobj == 0 {
451 return Err("nobj must be positive");
452 }
453 if values
454 .iter()
455 .any(|row| row.len() < nobj || row[..nobj].iter().any(|value| !value.is_finite()))
456 {
457 return Err("every value row must contain nobj finite values");
458 }
459 let mut front = Vec::new();
460 for candidate in 0..values.len() {
461 let dominated = (0..values.len()).any(|other| {
462 other != candidate
463 && (0..nobj).all(|j| values[other][j] <= values[candidate][j])
464 && (0..nobj).any(|j| values[other][j] < values[candidate][j])
465 });
466 if !dominated {
467 front.push(candidate);
468 }
469 }
470 front.sort_by(|&left, &right| values[left][0].total_cmp(&values[right][0]));
471 Ok(front)
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn bounds() -> RetryBounds {
479 RetryBounds::new(vec![-2.0, -2.0], vec![2.0, 2.0]).unwrap()
480 }
481
482 #[test]
483 fn scalarization_matches_python_formula_and_penalty() {
484 assert_eq!(scalarize(&[3.0, 4.0], &[1.0, 1.0], 0, 2.0), 5.0);
485 let penalized = scalarize(&[3.0, 4.0, 0.5], &[1.0, 1.0, 2.0], 1, 2.0);
486 assert!((penalized - (26.0_f64.sqrt() + 2.0)).abs() < 1e-12);
487 assert_eq!(scalarize(&[1.0], &[1.0, 2.0], 0, 2.0), NAN_REPLACEMENT);
488 }
489
490 #[test]
491 fn validates_configuration() {
492 assert!(
493 MoRetryConfig::new(Vec::new(), Vec::new())
494 .validate()
495 .is_err()
496 );
497 let mut config = MoRetryConfig::new(vec![0.0, 0.0], vec![1.0, 1.0]);
498 config.ncon = 2;
499 assert!(config.validate().is_err());
500 config.ncon = 0;
501 config.value_exp = 0.0;
502 assert!(config.validate().is_err());
503 }
504
505 #[test]
506 fn weighted_retry_is_deterministic_and_retains_vectors() {
507 let objective = |x: &[f64]| vec![x[0] * x[0], (x[1] - 1.0).powi(2)];
508 let mut config = MoRetryConfig::new(vec![0.5, 0.5], vec![1.5, 1.5]);
509 config.retry = RetryConfig {
510 num_retries: 12,
511 workers: 1,
512 capacity: 5,
513 seed: 123,
514 statistic_num: 3,
515 ..Default::default()
516 };
517 let run = |weighted: &WeightedObjective<'_, _>, context: &RetryContext| {
518 let mut rng = Rng::new(context.seed);
519 let x = vec![-2.0 + 4.0 * rng.uniform01(), -2.0 + 4.0 * rng.uniform01()];
520 RetryRunResult {
521 y: weighted.eval_scalar(&x),
522 x,
523 evaluations: 1,
524 }
525 };
526 let first = moretry(&objective, &bounds(), &config, run).unwrap();
527 let second = moretry(&objective, &bounds(), &config, run).unwrap();
528 assert_eq!(first.entries, second.entries);
529 assert_eq!(first.runs, 12);
530 assert_eq!(first.evaluations, 12);
531 assert!(first.success);
532 assert!(first.entries.len() <= 5);
533 assert!(first.entries.iter().all(|entry| entry.y.len() == 2));
534 assert!(first.improvements.len() <= 3);
535 }
536
537 #[test]
538 fn value_limits_filter_and_stop_works() {
539 let objective = |_: &[f64]| vec![0.0, 2.0];
540 let mut config = MoRetryConfig::new(vec![1.0, 1.0], vec![1.0, 1.0]);
541 config.value_limits = Some(vec![1.0, 1.0]);
542 config.retry.num_retries = 3;
543 config.retry.workers = 1;
544 let filtered = moretry(&objective, &bounds(), &config, |weighted, _| {
545 let x = vec![0.0, 0.0];
546 RetryRunResult {
547 y: weighted.eval_scalar(&x),
548 x,
549 evaluations: 1,
550 }
551 })
552 .unwrap();
553 assert!(!filtered.success);
554 assert_eq!(filtered.runs, 3);
555
556 config.value_limits = None;
557 config.retry.stop_fitness = 3.0;
558 config.retry.num_retries = 20;
559 let stopped = moretry(&objective, &bounds(), &config, |weighted, _| {
560 let x = vec![0.0, 0.0];
561 RetryRunResult {
562 y: weighted.eval_scalar(&x),
563 x,
564 evaluations: 1,
565 }
566 })
567 .unwrap();
568 assert_eq!(stopped.runs, 1);
569 }
570
571 #[test]
572 fn pareto_indices_handles_tradeoffs_duplicates_and_dominance() {
573 let values = vec![
574 vec![0.0, 2.0],
575 vec![1.0, 1.0],
576 vec![2.0, 0.0],
577 vec![2.0, 2.0],
578 vec![1.0, 1.0],
579 ];
580 assert_eq!(pareto_indices(&values, 2).unwrap(), vec![0, 1, 4, 2]);
581 assert!(pareto_indices(&values, 0).is_err());
582 }
583}