Skip to main content

scirs2_optimize/multi_objective/
advanced.rs

1//! Advanced multi-objective optimization utilities (clean `Vec<f64>`-based API).
2//!
3//! Provides:
4//! - [`NsgaIII`] — struct-based NSGA-III many-objective optimizer
5//! - [`NsgaResult`] — unified result type
6//! - Free functions: [`dominates`], [`non_dominated_sort`], [`hypervolume`],
7//!   [`crowding_distance`], [`weighted_sum_optimize`], [`epsilon_constraint_optimize`]
8//!
9//! All functions operate on `Vec<f64>` and `&[f64]` rather than ndarray types,
10//! making them easy to use from pure Rust code without array wrappers.
11//!
12//! # Example
13//!
14//! ```rust
15//! use scirs2_optimize::multi_objective::advanced::{NsgaIII, dominates, non_dominated_sort};
16//!
17//! assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
18//!
19//! let sols = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0], vec![2.0, 3.0]];
20//! let fronts = non_dominated_sort(&sols);
21//! assert_eq!(fronts[0].len(), 3);
22//! ```
23
24use crate::error::{OptimizeError, OptimizeResult};
25use scirs2_core::random::rngs::StdRng;
26use scirs2_core::random::{Rng, RngExt, SeedableRng};
27
28// ─────────────────────────────────────────────────────────────────────────────
29// Core dominance utilities
30// ─────────────────────────────────────────────────────────────────────────────
31
32/// Returns `true` if objective vector `a` Pareto-dominates `b`.
33///
34/// `a` dominates `b` when:
35/// - `a[i] <= b[i]` for **all** objectives `i`, **and**
36/// - `a[j] < b[j]` for **at least one** objective `j`.
37///
38/// # Examples
39/// ```
40/// use scirs2_optimize::multi_objective::advanced::dominates;
41/// assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
42/// assert!(!dominates(&[1.0, 2.0], &[1.0, 2.0])); // equal
43/// assert!(!dominates(&[2.0, 1.0], &[1.0, 2.0])); // incomparable
44/// ```
45pub fn dominates(a: &[f64], b: &[f64]) -> bool {
46    if a.len() != b.len() || a.is_empty() {
47        return false;
48    }
49    let mut strictly_better = false;
50    for (&ai, &bi) in a.iter().zip(b.iter()) {
51        if ai > bi {
52            return false;
53        }
54        if ai < bi {
55            strictly_better = true;
56        }
57    }
58    strictly_better
59}
60
61/// Fast non-dominated sorting (NSGA-II style).
62///
63/// Returns a list of **fronts**, where each front is a `Vec<usize>` of solution
64/// indices into `solutions`. Front 0 is the Pareto front.
65///
66/// # Examples
67/// ```
68/// use scirs2_optimize::multi_objective::advanced::non_dominated_sort;
69/// let sols = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0], vec![4.0, 4.0]];
70/// let fronts = non_dominated_sort(&sols);
71/// assert_eq!(fronts[0].len(), 3);
72/// assert_eq!(fronts[1].len(), 1);
73/// ```
74pub fn non_dominated_sort(solutions: &[Vec<f64>]) -> Vec<Vec<usize>> {
75    let n = solutions.len();
76    let mut dom_count = vec![0usize; n];
77    let mut dom_set: Vec<Vec<usize>> = vec![Vec::new(); n];
78
79    for i in 0..n {
80        for j in (i + 1)..n {
81            if dominates(&solutions[i], &solutions[j]) {
82                dom_set[i].push(j);
83                dom_count[j] += 1;
84            } else if dominates(&solutions[j], &solutions[i]) {
85                dom_set[j].push(i);
86                dom_count[i] += 1;
87            }
88        }
89    }
90
91    let mut fronts: Vec<Vec<usize>> = Vec::new();
92    let mut current_front: Vec<usize> = (0..n).filter(|&i| dom_count[i] == 0).collect();
93
94    while !current_front.is_empty() {
95        let mut next_front = Vec::new();
96        for &p in &current_front {
97            for &q in &dom_set[p] {
98                dom_count[q] -= 1;
99                if dom_count[q] == 0 {
100                    next_front.push(q);
101                }
102            }
103        }
104        fronts.push(current_front);
105        current_front = next_front;
106    }
107
108    fronts
109}
110
111// ─────────────────────────────────────────────────────────────────────────────
112// Hypervolume indicator
113// ─────────────────────────────────────────────────────────────────────────────
114
115/// Compute the hypervolume indicator.
116///
117/// - Exact for 1-D and 2-D fronts (O(n log n)).
118/// - Monte Carlo approximation (100 000 samples) for dimensions ≥ 3.
119///
120/// # Examples
121/// ```
122/// use scirs2_optimize::multi_objective::advanced::hypervolume;
123/// let front = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
124/// let ref_pt = vec![3.0, 3.0];
125/// let hv = hypervolume(&front, &ref_pt);
126/// assert!((hv - 3.0).abs() < 1e-6);
127/// ```
128pub fn hypervolume(pareto_front: &[Vec<f64>], reference_point: &[f64]) -> f64 {
129    if pareto_front.is_empty() {
130        return 0.0;
131    }
132    match reference_point.len() {
133        0 => 0.0,
134        1 => hv_1d(pareto_front, reference_point),
135        2 => hv_2d(pareto_front, reference_point),
136        _ => hv_mc(pareto_front, reference_point, 100_000),
137    }
138}
139
140fn hv_1d(front: &[Vec<f64>], ref_pt: &[f64]) -> f64 {
141    let min_f = front
142        .iter()
143        .filter_map(|f| f.first().copied())
144        .fold(f64::INFINITY, f64::min);
145    (ref_pt[0] - min_f).max(0.0)
146}
147
148fn hv_2d(front: &[Vec<f64>], ref_pt: &[f64]) -> f64 {
149    let mut pts: Vec<(f64, f64)> = front
150        .iter()
151        .filter(|f| f.len() >= 2 && f[0] < ref_pt[0] && f[1] < ref_pt[1])
152        .map(|f| (f[0], f[1]))
153        .collect();
154
155    if pts.is_empty() {
156        return 0.0;
157    }
158
159    // Sweep line: process points left-to-right (ascending x), tracking minimum y seen
160    // so far.  For each point i the strip [x_i, x_{i+1}) × [min_y, ref_y) is added,
161    // where x_{n} = ref_pt[0].  This correctly handles dominated points and arbitrary
162    // orderings because min_y only decreases, preventing overcounting.
163    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
164
165    let n = pts.len();
166    let mut hv = 0.0f64;
167    let mut min_y = ref_pt[1];
168
169    for i in 0..n {
170        let (x_i, y_i) = pts[i];
171        let next_x = if i + 1 < n { pts[i + 1].0 } else { ref_pt[0] };
172        if y_i < min_y {
173            min_y = y_i;
174        }
175        hv += (next_x - x_i) * (ref_pt[1] - min_y);
176    }
177    hv
178}
179
180fn hv_mc(front: &[Vec<f64>], ref_pt: &[f64], n_samples: usize) -> f64 {
181    let n_obj = ref_pt.len();
182    let ideal: Vec<f64> = (0..n_obj)
183        .map(|j| {
184            front
185                .iter()
186                .filter_map(|f| f.get(j).copied())
187                .fold(f64::INFINITY, f64::min)
188        })
189        .collect();
190
191    let box_vol: f64 = (0..n_obj)
192        .map(|j| (ref_pt[j] - ideal[j]).max(0.0))
193        .product();
194    if box_vol <= 0.0 {
195        return 0.0;
196    }
197
198    let mut rng = StdRng::seed_from_u64(42);
199    let count = (0..n_samples)
200        .filter(|_| {
201            let sample: Vec<f64> = (0..n_obj)
202                .map(|j| ideal[j] + rng.random::<f64>() * (ref_pt[j] - ideal[j]))
203                .collect();
204            front
205                .iter()
206                .any(|f| f.len() >= n_obj && (0..n_obj).all(|j| f[j] <= sample[j]))
207        })
208        .count();
209
210    box_vol * count as f64 / n_samples as f64
211}
212
213// ─────────────────────────────────────────────────────────────────────────────
214// Crowding distance
215// ─────────────────────────────────────────────────────────────────────────────
216
217/// Compute crowding distances for a single Pareto front.
218///
219/// Boundary solutions receive `f64::INFINITY`.
220///
221/// # Examples
222/// ```
223/// use scirs2_optimize::multi_objective::advanced::crowding_distance;
224/// let front = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
225/// let d = crowding_distance(&front);
226/// assert_eq!(d[0], f64::INFINITY);
227/// assert_eq!(d[2], f64::INFINITY);
228/// assert!(d[1].is_finite());
229/// ```
230pub fn crowding_distance(front: &[Vec<f64>]) -> Vec<f64> {
231    let n = front.len();
232    if n == 0 {
233        return Vec::new();
234    }
235    if n <= 2 {
236        return vec![f64::INFINITY; n];
237    }
238
239    let n_obj = front[0].len();
240    let mut dist = vec![0.0f64; n];
241
242    for obj in 0..n_obj {
243        let mut order: Vec<usize> = (0..n).collect();
244        order.sort_by(|&a, &b| {
245            front[a][obj]
246                .partial_cmp(&front[b][obj])
247                .unwrap_or(std::cmp::Ordering::Equal)
248        });
249
250        let f_min = front[order[0]][obj];
251        let f_max = front[order[n - 1]][obj];
252        let range = f_max - f_min;
253
254        dist[order[0]] = f64::INFINITY;
255        dist[order[n - 1]] = f64::INFINITY;
256
257        if range < 1e-14 {
258            continue;
259        }
260
261        for k in 1..(n - 1) {
262            dist[order[k]] += (front[order[k + 1]][obj] - front[order[k - 1]][obj]) / range;
263        }
264    }
265
266    dist
267}
268
269// ─────────────────────────────────────────────────────────────────────────────
270// NsgaIII struct
271// ─────────────────────────────────────────────────────────────────────────────
272
273/// NSGA-III many-objective optimizer — struct-based API.
274///
275/// # Example
276/// ```rust
277/// use scirs2_optimize::multi_objective::advanced::NsgaIII;
278/// let mut nsga = NsgaIII::new(2);
279/// nsga.population_size = 20;
280/// nsga.n_generations = 5;
281/// let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 3];
282/// let result = nsga.optimize(
283///     |x| vec![x[0], 1.0 - x[0].sqrt()],
284///     &bounds,
285/// ).expect("valid input");
286/// assert!(!result.pareto_front.is_empty());
287/// ```
288#[derive(Debug, Clone)]
289pub struct NsgaIII {
290    /// Number of objectives.
291    pub n_objectives: usize,
292    /// Population size per generation.
293    pub population_size: usize,
294    /// Number of generations.
295    pub n_generations: usize,
296    /// SBX crossover probability.
297    pub crossover_prob: f64,
298    /// Polynomial mutation probability.
299    pub mutation_prob: f64,
300    /// Random seed.
301    pub seed: u64,
302    /// Number of reference-point divisions per objective.
303    pub n_divisions: usize,
304}
305
306impl NsgaIII {
307    /// Create a new NSGA-III optimizer for the given number of objectives.
308    pub fn new(n_objectives: usize) -> Self {
309        Self {
310            n_objectives,
311            population_size: 100,
312            n_generations: 100,
313            crossover_prob: 0.9,
314            mutation_prob: 0.01,
315            seed: 42,
316            n_divisions: 4,
317        }
318    }
319
320    /// Run NSGA-III to approximate the Pareto front.
321    pub fn optimize<F>(&self, objectives: F, bounds: &[(f64, f64)]) -> OptimizeResult<NsgaResult>
322    where
323        F: Fn(&[f64]) -> Vec<f64>,
324    {
325        if bounds.is_empty() {
326            return Err(OptimizeError::InvalidInput(
327                "bounds must not be empty".to_string(),
328            ));
329        }
330
331        let (pareto_dec, pareto_obj, n_eval) = run_nsga3_internal(
332            &objectives,
333            bounds,
334            self.n_objectives,
335            self.population_size,
336            self.n_generations,
337            self.crossover_prob,
338            self.mutation_prob,
339            self.seed,
340            self.n_divisions,
341        );
342
343        Ok(NsgaResult {
344            pareto_front: pareto_dec,
345            objective_values: pareto_obj,
346            n_evaluations: n_eval,
347        })
348    }
349}
350
351/// Result from [`NsgaIII::optimize`].
352#[derive(Debug, Clone)]
353pub struct NsgaResult {
354    /// Decision variable vectors for Pareto-optimal solutions.
355    pub pareto_front: Vec<Vec<f64>>,
356    /// Corresponding objective values.
357    pub objective_values: Vec<Vec<f64>>,
358    /// Total function evaluations.
359    pub n_evaluations: usize,
360}
361
362// ─────────────────────────────────────────────────────────────────────────────
363// Weighted-sum scalarization
364// ─────────────────────────────────────────────────────────────────────────────
365
366/// Minimize a weighted sum of objectives via multi-start coordinate descent.
367///
368/// # Returns
369/// `(x_best, f_best)` — optimal decision vector and objective values.
370pub fn weighted_sum_optimize<F>(
371    objectives: F,
372    weights: &[f64],
373    bounds: &[(f64, f64)],
374    n_starts: usize,
375    seed: u64,
376) -> (Vec<f64>, Vec<f64>)
377where
378    F: Fn(&[f64]) -> Vec<f64>,
379{
380    let n_vars = bounds.len();
381    let n_starts = n_starts.max(1);
382
383    let scalar = |x: &[f64]| -> f64 {
384        objectives(x)
385            .iter()
386            .zip(weights.iter())
387            .map(|(&fi, &wi)| wi * fi)
388            .sum::<f64>()
389    };
390
391    let mut rng = StdRng::seed_from_u64(seed);
392    let mut best_x = vec![0.0f64; n_vars];
393    let mut best_val = f64::INFINITY;
394
395    for _ in 0..n_starts {
396        let mut x: Vec<f64> = bounds
397            .iter()
398            .map(|&(lb, ub)| lb + rng.random::<f64>() * (ub - lb))
399            .collect();
400
401        let avg_range =
402            bounds.iter().map(|&(lb, ub)| (ub - lb).abs()).sum::<f64>() / n_vars.max(1) as f64;
403        let mut step = 0.1 * avg_range;
404        let mut fx = scalar(&x);
405
406        loop {
407            let mut improved = false;
408            for j in 0..n_vars {
409                let (lb, ub) = bounds[j];
410                for &dir in &[1.0f64, -1.0] {
411                    let xj_new = (x[j] + dir * step).clamp(lb, ub);
412                    let mut x_try = x.clone();
413                    x_try[j] = xj_new;
414                    let f_try = scalar(&x_try);
415                    if f_try < fx - 1e-12 {
416                        x[j] = xj_new;
417                        fx = f_try;
418                        improved = true;
419                    }
420                }
421            }
422            if !improved {
423                step *= 0.5;
424                if step < 1e-8 {
425                    break;
426                }
427            }
428        }
429
430        if fx < best_val {
431            best_val = fx;
432            best_x = x;
433        }
434    }
435
436    let best_f = objectives(&best_x);
437    (best_x, best_f)
438}
439
440// ─────────────────────────────────────────────────────────────────────────────
441// Epsilon-constraint method
442// ─────────────────────────────────────────────────────────────────────────────
443
444/// Epsilon-constraint method for multi-objective optimization.
445///
446/// Minimises the primary objective subject to bounds on all other objectives.
447///
448/// # Returns
449/// `(x_best, f_best)` — optimal decision vector and objective values.
450pub fn epsilon_constraint_optimize<F>(
451    objectives: F,
452    primary_idx: usize,
453    epsilon_bounds: &[(f64, f64)],
454    bounds: &[(f64, f64)],
455    n_starts: usize,
456    seed: u64,
457) -> (Vec<f64>, Vec<f64>)
458where
459    F: Fn(&[f64]) -> Vec<f64>,
460{
461    let n_vars = bounds.len();
462    let n_starts = n_starts.max(1);
463    let penalty_rho = 1e4;
464
465    let penalized = |x: &[f64]| -> f64 {
466        let f = objectives(x);
467        let primary = f.get(primary_idx).copied().unwrap_or(f64::INFINITY);
468        let mut viol = 0.0f64;
469        let mut eps_idx = 0usize;
470        for (i, &fi) in f.iter().enumerate() {
471            if i == primary_idx {
472                continue;
473            }
474            if let Some(&(lb, ub)) = epsilon_bounds.get(eps_idx) {
475                viol += (lb - fi).max(0.0) + (fi - ub).max(0.0);
476            }
477            eps_idx += 1;
478        }
479        primary + penalty_rho * viol
480    };
481
482    let mut rng = StdRng::seed_from_u64(seed);
483    let mut best_x = vec![0.0f64; n_vars];
484    let mut best_val = f64::INFINITY;
485
486    for _ in 0..n_starts {
487        let mut x: Vec<f64> = bounds
488            .iter()
489            .map(|&(lb, ub)| lb + rng.random::<f64>() * (ub - lb))
490            .collect();
491
492        let avg_range =
493            bounds.iter().map(|&(lb, ub)| (ub - lb).abs()).sum::<f64>() / n_vars.max(1) as f64;
494        let mut step = 0.1 * avg_range;
495        let mut fx = penalized(&x);
496
497        loop {
498            let mut improved = false;
499            for j in 0..n_vars {
500                let (lb, ub) = bounds[j];
501                for &dir in &[1.0f64, -1.0] {
502                    let xj_new = (x[j] + dir * step).clamp(lb, ub);
503                    let mut x_try = x.clone();
504                    x_try[j] = xj_new;
505                    let f_try = penalized(&x_try);
506                    if f_try < fx - 1e-12 {
507                        x[j] = xj_new;
508                        fx = f_try;
509                        improved = true;
510                    }
511                }
512            }
513            if !improved {
514                step *= 0.5;
515                if step < 1e-8 {
516                    break;
517                }
518            }
519        }
520
521        if fx < best_val {
522            best_val = fx;
523            best_x = x;
524        }
525    }
526
527    let best_f = objectives(&best_x);
528    (best_x, best_f)
529}
530
531// ─────────────────────────────────────────────────────────────────────────────
532// Internal NSGA-III evolutionary engine
533// ─────────────────────────────────────────────────────────────────────────────
534
535#[allow(clippy::too_many_arguments)]
536fn run_nsga3_internal<F>(
537    objectives: &F,
538    bounds: &[(f64, f64)],
539    n_obj: usize,
540    pop_size: usize,
541    n_gen: usize,
542    cx_prob: f64,
543    mut_prob: f64,
544    seed: u64,
545    n_div: usize,
546) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, usize)
547where
548    F: Fn(&[f64]) -> Vec<f64>,
549{
550    let n_vars = bounds.len();
551    let mut rng = StdRng::seed_from_u64(seed);
552    let mut n_eval = 0usize;
553
554    let ref_pts = gen_reference_points(n_obj, n_div);
555
556    let mut pop: Vec<Vec<f64>> = (0..pop_size)
557        .map(|_| {
558            bounds
559                .iter()
560                .map(|&(lb, ub)| lb + rng.random::<f64>() * (ub - lb))
561                .collect()
562        })
563        .collect();
564
565    let mut obj_vals: Vec<Vec<f64>> = pop
566        .iter()
567        .map(|x| {
568            n_eval += 1;
569            objectives(x)
570        })
571        .collect();
572
573    for _gen in 0..n_gen {
574        let mut offspring: Vec<Vec<f64>> = Vec::with_capacity(pop_size);
575
576        while offspring.len() < pop_size {
577            let p1 = rng.random_range(0..pop_size);
578            let p2 = rng.random_range(0..pop_size);
579
580            let (c1, c2) = if rng.random::<f64>() < cx_prob {
581                sbx_crossover(&pop[p1], &pop[p2], bounds, &mut rng, 20.0)
582            } else {
583                (pop[p1].clone(), pop[p2].clone())
584            };
585
586            let c1 = poly_mutation(c1, bounds, &mut rng, mut_prob, 20.0);
587            let c2 = poly_mutation(c2, bounds, &mut rng, mut_prob, 20.0);
588
589            offspring.push(c1);
590            if offspring.len() < pop_size {
591                offspring.push(c2);
592            }
593        }
594
595        let offspring_obj: Vec<Vec<f64>> = offspring
596            .iter()
597            .map(|x| {
598                n_eval += 1;
599                objectives(x)
600            })
601            .collect();
602
603        let combined: Vec<Vec<f64>> = pop.iter().chain(offspring.iter()).cloned().collect();
604        let combined_obj: Vec<Vec<f64>> = obj_vals
605            .iter()
606            .chain(offspring_obj.iter())
607            .cloned()
608            .collect();
609
610        let fronts = non_dominated_sort(&combined_obj);
611
612        let mut new_pop: Vec<Vec<f64>> = Vec::with_capacity(pop_size);
613        let mut new_obj: Vec<Vec<f64>> = Vec::with_capacity(pop_size);
614        let mut critical_indices: Vec<usize> = Vec::new();
615
616        for front in &fronts {
617            if new_pop.len() + front.len() <= pop_size {
618                for &idx in front {
619                    new_pop.push(combined[idx].clone());
620                    new_obj.push(combined_obj[idx].clone());
621                }
622            } else {
623                critical_indices = front.clone();
624                break;
625            }
626        }
627
628        let remaining = pop_size.saturating_sub(new_pop.len());
629        if remaining > 0 && !critical_indices.is_empty() {
630            let selected = niche_select(
631                &critical_indices,
632                &combined_obj,
633                &new_obj,
634                &ref_pts,
635                remaining,
636                n_obj,
637                &mut rng,
638            );
639            for idx in selected {
640                new_pop.push(combined[idx].clone());
641                new_obj.push(combined_obj[idx].clone());
642            }
643        }
644
645        pop = new_pop;
646        obj_vals = new_obj;
647    }
648
649    let fronts = non_dominated_sort(&obj_vals);
650    let pareto_idx = fronts.into_iter().next().unwrap_or_default();
651    let dec: Vec<Vec<f64>> = pareto_idx.iter().map(|&i| pop[i].clone()).collect();
652    let obj: Vec<Vec<f64>> = pareto_idx.iter().map(|&i| obj_vals[i].clone()).collect();
653
654    (dec, obj, n_eval)
655}
656
657fn gen_reference_points(n_obj: usize, n_div: usize) -> Vec<Vec<f64>> {
658    let mut points = Vec::new();
659    let mut cur = vec![0usize; n_obj];
660    gen_ref_rec(n_obj, n_div, 0, n_div, &mut cur, &mut points);
661    points
662}
663
664fn gen_ref_rec(
665    n_obj: usize,
666    n_div: usize,
667    idx: usize,
668    rem: usize,
669    cur: &mut Vec<usize>,
670    points: &mut Vec<Vec<f64>>,
671) {
672    if idx == n_obj - 1 {
673        cur[idx] = rem;
674        points.push(
675            cur.iter()
676                .map(|&v| v as f64 / n_div.max(1) as f64)
677                .collect(),
678        );
679        return;
680    }
681    for v in 0..=rem {
682        cur[idx] = v;
683        gen_ref_rec(n_obj, n_div, idx + 1, rem - v, cur, points);
684    }
685}
686
687#[allow(clippy::too_many_arguments)]
688fn niche_select(
689    critical: &[usize],
690    all_obj: &[Vec<f64>],
691    selected_obj: &[Vec<f64>],
692    ref_pts: &[Vec<f64>],
693    n_select: usize,
694    n_obj: usize,
695    rng: &mut StdRng,
696) -> Vec<usize> {
697    let n_ref = ref_pts.len();
698    if n_ref == 0 {
699        let mut idx = critical.to_vec();
700        idx.truncate(n_select);
701        return idx;
702    }
703
704    let all_selected_obj: Vec<&Vec<f64>> = selected_obj
705        .iter()
706        .chain(critical.iter().map(|&i| &all_obj[i]))
707        .collect();
708
709    let ideal: Vec<f64> = (0..n_obj)
710        .map(|j| {
711            all_selected_obj
712                .iter()
713                .filter_map(|f| f.get(j).copied())
714                .fold(f64::INFINITY, f64::min)
715        })
716        .collect();
717    let nadir: Vec<f64> = (0..n_obj)
718        .map(|j| {
719            all_selected_obj
720                .iter()
721                .filter_map(|f| f.get(j).copied())
722                .fold(f64::NEG_INFINITY, f64::max)
723        })
724        .collect();
725
726    let norm = |f: &[f64]| -> Vec<f64> {
727        (0..n_obj)
728            .map(|j| {
729                let range = (nadir[j] - ideal[j]).max(1e-10);
730                (f.get(j).copied().unwrap_or(0.0) - ideal[j]) / range
731            })
732            .collect()
733    };
734
735    let line_dist = |f_n: &[f64], rp: &[f64]| -> f64 {
736        let rp_norm: f64 = rp.iter().map(|&r| r * r).sum::<f64>().sqrt().max(1e-12);
737        let dot: f64 = f_n.iter().zip(rp).map(|(&fi, &ri)| fi * ri).sum();
738        let proj = dot / rp_norm;
739        let proj_pt: Vec<f64> = rp.iter().map(|&ri| proj * ri / rp_norm).collect();
740        f_n.iter()
741            .zip(&proj_pt)
742            .map(|(&fi, &pi)| (fi - pi).powi(2))
743            .sum::<f64>()
744            .sqrt()
745    };
746
747    let mut niche_count = vec![0usize; n_ref];
748    for f in selected_obj {
749        let fn_ = norm(f);
750        let best = (0..n_ref)
751            .min_by(|&r1, &r2| {
752                line_dist(&fn_, &ref_pts[r1])
753                    .partial_cmp(&line_dist(&fn_, &ref_pts[r2]))
754                    .unwrap_or(std::cmp::Ordering::Equal)
755            })
756            .unwrap_or(0);
757        niche_count[best] += 1;
758    }
759
760    let info: Vec<(usize, usize, f64)> = critical
761        .iter()
762        .map(|&idx| {
763            let fn_ = norm(&all_obj[idx]);
764            let (r, d) = (0..n_ref)
765                .map(|r| (r, line_dist(&fn_, &ref_pts[r])))
766                .min_by(|(_, d1), (_, d2)| d1.partial_cmp(d2).unwrap_or(std::cmp::Ordering::Equal))
767                .unwrap_or((0, f64::INFINITY));
768            (idx, r, d)
769        })
770        .collect();
771
772    let mut remaining = info;
773    let mut selected = Vec::with_capacity(n_select);
774
775    while selected.len() < n_select && !remaining.is_empty() {
776        let min_nc = remaining
777            .iter()
778            .map(|&(_, r, _)| niche_count[r])
779            .min()
780            .unwrap_or(0);
781        let cands: Vec<usize> = remaining
782            .iter()
783            .enumerate()
784            .filter(|(_, &(_, r, _))| niche_count[r] == min_nc)
785            .map(|(i, _)| i)
786            .collect();
787
788        let chosen = if min_nc == 0 {
789            cands[rng.random_range(0..cands.len())]
790        } else {
791            *cands
792                .iter()
793                .min_by(|&&a, &&b| {
794                    remaining[a]
795                        .2
796                        .partial_cmp(&remaining[b].2)
797                        .unwrap_or(std::cmp::Ordering::Equal)
798                })
799                .unwrap_or(&cands[0])
800        };
801
802        let (sol, ref_idx, _) = remaining.remove(chosen);
803        selected.push(sol);
804        niche_count[ref_idx] += 1;
805    }
806
807    selected
808}
809
810fn sbx_crossover(
811    p1: &[f64],
812    p2: &[f64],
813    bounds: &[(f64, f64)],
814    rng: &mut StdRng,
815    eta: f64,
816) -> (Vec<f64>, Vec<f64>) {
817    let mut c1 = p1.to_vec();
818    let mut c2 = p2.to_vec();
819    for i in 0..p1.len() {
820        let (lb, ub) = bounds[i];
821        if rng.random::<f64>() > 0.5 {
822            continue;
823        }
824        let u: f64 = rng.random();
825        let beta: f64 = if u <= 0.5 {
826            (2.0_f64 * u).powf(1.0_f64 / (eta + 1.0))
827        } else {
828            (1.0_f64 / (2.0_f64 * (1.0 - u))).powf(1.0_f64 / (eta + 1.0))
829        };
830        c1[i] = (0.5 * ((1.0 + beta) * p1[i] + (1.0 - beta) * p2[i])).clamp(lb, ub);
831        c2[i] = (0.5 * ((1.0 - beta) * p1[i] + (1.0 + beta) * p2[i])).clamp(lb, ub);
832    }
833    (c1, c2)
834}
835
836fn poly_mutation(
837    mut x: Vec<f64>,
838    bounds: &[(f64, f64)],
839    rng: &mut StdRng,
840    prob: f64,
841    eta: f64,
842) -> Vec<f64> {
843    for i in 0..x.len() {
844        if rng.random::<f64>() >= prob {
845            continue;
846        }
847        let (lb, ub) = bounds[i];
848        let range = (ub - lb).max(1e-12);
849        let d1 = (x[i] - lb) / range;
850        let d2 = (ub - x[i]) / range;
851        let u: f64 = rng.random();
852        let dq = if u <= 0.5 {
853            let v = 2.0 * u + (1.0 - 2.0 * u) * (1.0 - d1).powf(eta + 1.0);
854            v.powf(1.0 / (eta + 1.0)) - 1.0
855        } else {
856            let v = 2.0 * (1.0 - u) + 2.0 * (u - 0.5) * (1.0 - d2).powf(eta + 1.0);
857            1.0 - v.powf(1.0 / (eta + 1.0))
858        };
859        x[i] = (x[i] + dq * range).clamp(lb, ub);
860    }
861    x
862}
863
864// ─────────────────────────────────────────────────────────────────────────────
865// Tests
866// ─────────────────────────────────────────────────────────────────────────────
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use approx::assert_abs_diff_eq;
872
873    #[test]
874    fn test_dominates_basic() {
875        assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
876        assert!(!dominates(&[1.0, 2.0], &[1.0, 2.0]));
877        assert!(!dominates(&[2.0, 1.0], &[1.0, 2.0]));
878    }
879
880    #[test]
881    fn test_non_dominated_sort_basic() {
882        let sols = vec![
883            vec![1.0, 3.0],
884            vec![2.0, 2.0],
885            vec![3.0, 1.0],
886            vec![4.0, 4.0],
887        ];
888        let fronts = non_dominated_sort(&sols);
889        assert_eq!(fronts[0].len(), 3);
890        assert!(fronts[1].contains(&3));
891    }
892
893    #[test]
894    fn test_hypervolume_2d() {
895        let front = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
896        let ref_pt = vec![3.0, 3.0];
897        let hv = hypervolume(&front, &ref_pt);
898        assert_abs_diff_eq!(hv, 3.0, epsilon = 1e-10);
899    }
900
901    #[test]
902    fn test_hypervolume_empty() {
903        assert_eq!(hypervolume(&[], &[3.0, 3.0]), 0.0);
904    }
905
906    #[test]
907    fn test_crowding_distance_boundary() {
908        let front = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
909        let d = crowding_distance(&front);
910        assert_eq!(d[0], f64::INFINITY);
911        assert_eq!(d[2], f64::INFINITY);
912        assert!(d[1].is_finite());
913    }
914
915    #[test]
916    fn test_nsga3_basic() {
917        let mut nsga = NsgaIII::new(2);
918        nsga.population_size = 10;
919        nsga.n_generations = 3;
920        nsga.n_divisions = 2;
921
922        let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 2];
923        let result = nsga
924            .optimize(|x| vec![x[0], 1.0 - x[0].sqrt()], &bounds)
925            .expect("failed to create result");
926        assert!(!result.pareto_front.is_empty());
927        assert!(result.n_evaluations > 0);
928    }
929
930    #[test]
931    fn test_nsga3_empty_bounds_err() {
932        let nsga = NsgaIII::new(2);
933        assert!(nsga.optimize(|x| vec![x[0]], &[]).is_err());
934    }
935
936    #[test]
937    fn test_weighted_sum_single() {
938        let (x, f) = weighted_sum_optimize(|x| vec![x[0].powi(2)], &[1.0], &[(0.0, 2.0)], 5, 0);
939        assert!(x[0] < 0.2, "x near 0, got {}", x[0]);
940        assert!(f[0] < 0.05, "f near 0, got {}", f[0]);
941    }
942
943    #[test]
944    fn test_epsilon_constraint_basic() {
945        // min f0=x0 s.t. 0.3<=f1=x1<=0.7, x in [0,1]^2
946        let (x, f) = epsilon_constraint_optimize(
947            |x| vec![x[0], x[1]],
948            0,
949            &[(0.3, 0.7)],
950            &[(0.0, 1.0), (0.0, 1.0)],
951            5,
952            42,
953        );
954        assert!(x[0] < 0.2, "x[0] near 0, got {}", x[0]);
955        assert!(f[1] >= 0.2 && f[1] <= 0.8, "f[1] in bounds, got {}", f[1]);
956    }
957
958    #[test]
959    fn test_hypervolume_3d_positive() {
960        let front = vec![
961            vec![0.1, 0.5, 0.9],
962            vec![0.5, 0.1, 0.9],
963            vec![0.9, 0.9, 0.1],
964        ];
965        let ref_pt = vec![1.0, 1.0, 1.0];
966        let hv = hypervolume(&front, &ref_pt);
967        assert!(hv > 0.0, "3D HV should be positive, got {}", hv);
968    }
969}