Skip to main content

scirs2_optimize/multiobjective/
moead.rs

1//! MOEA/D: Multi-Objective Evolutionary Algorithm based on Decomposition
2//!
3//! Implements the algorithm by Zhang & Li (2007).  The core idea is to
4//! decompose the multi-objective problem into *N* scalar sub-problems using
5//! weight vectors and to exploit neighbourhood relationships during evolution.
6//!
7//! **Algorithm outline (per generation):**
8//!
9//! 1. For each sub-problem *i* (weight vector λ_i):
10//!    a. Choose mating partners from the neighbourhood B(i) of size T.
11//!    b. Generate offspring y by differential evolution (DE/rand/1).
12//!    c. Update ideal point z*.
13//!    d. For each j ∈ B(i), replace x_j with y if g^te(y|λ_j,z*) ≤ g^te(x_j|λ_j,z*).
14//! 2. Collect the final population as the Pareto-front approximation.
15//!
16//! # References
17//!
18//! - Zhang, Q., & Li, H. (2007). MOEA/D: A multiobjective evolutionary algorithm
19//!   based on decomposition. *IEEE TEC*, 11(6), 712–731.
20//! - Li, H., & Zhang, Q. (2009). Multiobjective optimization problems with
21//!   complicated Pareto sets, MOEA/D and NSGA-II.  *IEEE TEC*, 13(2), 284–302.
22
23use crate::error::OptimizeResult;
24use crate::multiobjective::indicators::{dominates, non_dominated_sort};
25use crate::multiobjective::nsga2::Individual;
26use scirs2_core::random::rngs::StdRng;
27use scirs2_core::random::{Rng, SeedableRng};
28use scirs2_core::RngExt;
29
30// ─────────────────────────────────────────────────────────────────────────────
31// Public types
32// ─────────────────────────────────────────────────────────────────────────────
33
34/// Configuration for the MOEA/D algorithm.
35#[derive(Debug, Clone)]
36pub struct MoeadConfig {
37    /// Number of sub-problems (= number of weight vectors = population size).
38    /// Default 100.
39    pub population_size: usize,
40    /// Number of generations.  Default 200.
41    pub n_generations: usize,
42    /// Neighbourhood size *T* (number of weight vectors considered as
43    /// neighbours).  Default 20 (or population_size/5, whichever is smaller).
44    pub n_neighbors: usize,
45    /// Probability of selecting mating partners from neighbourhood vs. whole
46    /// population.  Default 0.9.
47    pub delta: f64,
48    /// Number of objectives.  Must be ≥ 2.
49    pub n_objectives: usize,
50    /// RNG seed for reproducibility.
51    pub seed: u64,
52}
53
54impl Default for MoeadConfig {
55    fn default() -> Self {
56        Self {
57            population_size: 100,
58            n_generations: 200,
59            n_neighbors: 20,
60            delta: 0.9,
61            n_objectives: 2,
62            seed: 12345,
63        }
64    }
65}
66
67/// Result returned by [`moead`].
68#[derive(Debug)]
69pub struct MoeadResult {
70    /// Non-dominated solutions extracted from the final population.
71    pub pareto_front: Vec<Individual>,
72    /// Weight vectors used for decomposition (one per sub-problem).
73    pub weight_vectors: Vec<Vec<f64>>,
74    /// Number of generations executed.
75    pub n_generations: usize,
76}
77
78// ─────────────────────────────────────────────────────────────────────────────
79// Main entry point
80// ─────────────────────────────────────────────────────────────────────────────
81
82/// Run MOEA/D on a multi-objective optimisation problem.
83///
84/// # Arguments
85/// * `bounds`     - Decision-variable bounds `[(lo, hi); n_vars]`.
86/// * `objectives` - Closure mapping a gene vector to an objective vector
87///   (all minimised).
88/// * `config`     - Algorithm hyper-parameters.
89///
90/// # Errors
91/// Returns an error for empty bounds, degenerate bound intervals, or if
92/// `n_objectives < 2`.
93///
94/// # Examples
95/// ```
96/// use scirs2_optimize::multiobjective::moead::{moead, MoeadConfig};
97///
98/// let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 10];
99/// let mut cfg = MoeadConfig::default();
100/// cfg.population_size = 20;
101/// cfg.n_generations   = 5;
102/// cfg.n_objectives    = 2;
103///
104/// let result = moead(&bounds, |x| {
105///     let f1 = x[0];
106///     let g = 1.0 + 9.0 * x[1..].iter().sum::<f64>() / (x.len()-1) as f64;
107///     vec![f1, g * (1.0 - (f1/g).sqrt())]
108/// }, cfg).expect("valid input");
109///
110/// assert!(!result.pareto_front.is_empty());
111/// ```
112pub fn moead<F>(
113    bounds: &[(f64, f64)],
114    objectives: F,
115    config: MoeadConfig,
116) -> OptimizeResult<MoeadResult>
117where
118    F: Fn(&[f64]) -> Vec<f64>,
119{
120    use crate::error::OptimizeError;
121
122    if config.n_objectives < 2 {
123        return Err(OptimizeError::InvalidInput(
124            "n_objectives must be >= 2".to_string(),
125        ));
126    }
127    if bounds.is_empty() {
128        return Err(OptimizeError::InvalidInput(
129            "bounds must be non-empty".to_string(),
130        ));
131    }
132    for (i, &(lo, hi)) in bounds.iter().enumerate() {
133        if lo >= hi {
134            return Err(OptimizeError::InvalidInput(format!(
135                "bound[{i}]: lo ({lo}) must be < hi ({hi})"
136            )));
137        }
138    }
139
140    let n_vars = bounds.len();
141    let pop_size = config.population_size.max(4);
142    let n_obj = config.n_objectives;
143    let t = config.n_neighbors.min(pop_size).max(2);
144
145    let mut rng = StdRng::seed_from_u64(config.seed);
146
147    // ── 1. Generate uniform weight vectors ──────────────────────────────────
148    let weight_vectors = generate_weight_vectors(n_obj, pop_size, &mut rng);
149    let actual_pop_size = weight_vectors.len();
150
151    // ── 2. Build neighbourhood lookup table ─────────────────────────────────
152    let neighborhoods = build_neighborhood(&weight_vectors, t);
153
154    // ── 3. Initialise population + ideal point ───────────────────────────────
155    let mut population: Vec<Individual> = (0..actual_pop_size)
156        .map(|_| {
157            let genes = random_genes(bounds, &mut rng);
158            let objs = objectives(&genes);
159            Individual::new(genes, objs)
160        })
161        .collect();
162
163    // Ideal point z* = component-wise minimum of all objective vectors
164    let mut ideal_point: Vec<f64> = vec![f64::INFINITY; n_obj];
165    for ind in &population {
166        for (k, &v) in ind.objectives.iter().enumerate() {
167            if v < ideal_point[k] {
168                ideal_point[k] = v;
169            }
170        }
171    }
172
173    // ── 4. Main evolutionary loop ────────────────────────────────────────────
174    for _ in 0..config.n_generations {
175        for i in 0..actual_pop_size {
176            // Select mating pool: neighbourhood or whole population
177            let use_neighborhood = rng.random::<f64>() < config.delta;
178            let mating_pool = if use_neighborhood {
179                &neighborhoods[i]
180            } else {
181                // Use all indices (implicit via actual_pop_size)
182                &neighborhoods[i] // fall back to neighborhood if whole-pop not pre-built
183            };
184
185            // Generate offspring using DE/rand/1 with two random parents from pool
186            let offspring_genes = de_offspring(&population, mating_pool, bounds, &mut rng);
187            let offspring_objs = objectives(&offspring_genes);
188            let offspring = Individual::new(offspring_genes, offspring_objs);
189
190            // Update ideal point
191            for (k, &v) in offspring.objectives.iter().enumerate() {
192                if v < ideal_point[k] {
193                    ideal_point[k] = v;
194                }
195            }
196
197            // Update neighbourhood solutions
198            for &j in mating_pool {
199                let w = &weight_vectors[j];
200                let g_offspring = tchebycheff_scalarization(&offspring.objectives, w, &ideal_point);
201                let g_current =
202                    tchebycheff_scalarization(&population[j].objectives, w, &ideal_point);
203                if g_offspring <= g_current {
204                    population[j] = offspring.clone();
205                }
206            }
207        }
208    }
209
210    // ── 5. Extract Pareto front from final population ────────────────────────
211    let obj_vecs: Vec<Vec<f64>> = population
212        .iter()
213        .map(|ind| ind.objectives.clone())
214        .collect();
215    let fronts = non_dominated_sort(&obj_vecs);
216
217    let pareto_front: Vec<Individual> = if fronts.is_empty() {
218        population.clone()
219    } else {
220        fronts[0].iter().map(|&i| population[i].clone()).collect()
221    };
222
223    Ok(MoeadResult {
224        pareto_front,
225        weight_vectors,
226        n_generations: config.n_generations,
227    })
228}
229
230// ─────────────────────────────────────────────────────────────────────────────
231// Tchebycheff scalarization
232// ─────────────────────────────────────────────────────────────────────────────
233
234/// Tchebycheff (Chebyshev) scalarization.
235///
236/// g^te(f | w, z*) = max_k { w_k * |f_k - z_k*| }
237///
238/// This is the most widely used scalarization in MOEA/D and handles
239/// non-convex Pareto fronts, unlike the weighted-sum approach.
240pub fn tchebycheff_scalarization(objectives: &[f64], weights: &[f64], ideal_point: &[f64]) -> f64 {
241    objectives
242        .iter()
243        .zip(weights.iter())
244        .zip(ideal_point.iter())
245        .map(|((f, w), z)| w * (f - z).abs())
246        .fold(f64::NEG_INFINITY, f64::max)
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// Weight vector generation (simplex lattice)
251// ─────────────────────────────────────────────────────────────────────────────
252
253/// Generate approximately `target_n` uniformly distributed weight vectors on
254/// the unit simplex for `n_obj` objectives.
255///
256/// Uses the Das–Dennis normal boundary intersection (NBI) lattice.  The actual
257/// number of generated vectors may differ slightly from `target_n` due to
258/// combinatorial constraints; it is always at least 2.
259///
260/// # Algorithm
261/// Find the largest integer *H* (number of divisions) such that
262/// C(H + M - 1, M - 1) ≤ target_n, then enumerate all non-negative integer
263/// tuples (h_1,...,h_M) with sum H and map each to w_i = h_i / H.
264pub fn generate_weight_vectors(n_obj: usize, target_n: usize, rng: &mut StdRng) -> Vec<Vec<f64>> {
265    if n_obj == 1 {
266        return vec![vec![1.0]];
267    }
268
269    // Find H: largest integer such that C(H+M-1, M-1) <= target_n
270    let mut h = 1usize;
271    while combinations(h + n_obj, n_obj - 1) <= target_n {
272        h += 1;
273    }
274    h -= 1;
275    if h == 0 {
276        h = 1;
277    }
278
279    let mut vectors: Vec<Vec<f64>> = Vec::new();
280    enumerate_simplex(&mut vectors, n_obj, h);
281
282    // Normalise (divide by H to get values in [0,1] summing to 1)
283    for v in &mut vectors {
284        for x in v.iter_mut() {
285            *x /= h as f64;
286        }
287    }
288
289    // If we have fewer than 2 vectors, add random ones to fill target_n
290    while vectors.len() < 2 {
291        vectors.push(random_simplex_point(n_obj, rng));
292    }
293
294    vectors
295}
296
297/// Enumerate all integer tuples (a_0,...,a_{M-1}) with sum H and M components.
298fn enumerate_simplex(out: &mut Vec<Vec<f64>>, n_obj: usize, h: usize) {
299    let mut current = vec![0.0f64; n_obj];
300    enumerate_simplex_rec(out, &mut current, n_obj, h, 0, h);
301}
302
303fn enumerate_simplex_rec(
304    out: &mut Vec<Vec<f64>>,
305    current: &mut Vec<f64>,
306    n_obj: usize,
307    h: usize,
308    index: usize,
309    remaining: usize,
310) {
311    if index == n_obj - 1 {
312        current[index] = remaining as f64;
313        out.push(current.clone());
314        return;
315    }
316    for i in 0..=remaining {
317        current[index] = i as f64;
318        enumerate_simplex_rec(out, current, n_obj, h, index + 1, remaining - i);
319    }
320}
321
322/// Binomial coefficient C(n, k).
323fn combinations(n: usize, k: usize) -> usize {
324    if k == 0 || k == n {
325        return 1;
326    }
327    if k > n {
328        return 0;
329    }
330    let k = k.min(n - k);
331    let mut result = 1usize;
332    for i in 0..k {
333        result = result.saturating_mul(n - i) / (i + 1);
334    }
335    result
336}
337
338/// Generate a single random point on the unit simplex (for fallback padding).
339fn random_simplex_point(n: usize, rng: &mut StdRng) -> Vec<f64> {
340    // Exponential sampling: sample Exp(1) and normalise
341    let exps: Vec<f64> = (0..n)
342        .map(|_| -rng.random::<f64>().ln().max(1e-15))
343        .collect();
344    let sum: f64 = exps.iter().sum();
345    exps.iter().map(|&e| e / sum).collect()
346}
347
348// ─────────────────────────────────────────────────────────────────────────────
349// Neighbourhood construction
350// ─────────────────────────────────────────────────────────────────────────────
351
352/// Build neighbourhood table.
353///
354/// For each weight vector i, `result[i]` contains the indices of the T weight
355/// vectors nearest to i (by Euclidean distance, including i itself).
356pub fn build_neighborhood(weight_vectors: &[Vec<f64>], t: usize) -> Vec<Vec<usize>> {
357    let n = weight_vectors.len();
358    let t = t.min(n);
359
360    weight_vectors
361        .iter()
362        .map(|wi| {
363            // Compute distance from wi to every other weight vector
364            let mut dist_idx: Vec<(f64, usize)> = weight_vectors
365                .iter()
366                .enumerate()
367                .map(|(j, wj)| {
368                    let d = wi
369                        .iter()
370                        .zip(wj.iter())
371                        .map(|(a, b)| (a - b).powi(2))
372                        .sum::<f64>()
373                        .sqrt();
374                    (d, j)
375                })
376                .collect();
377
378            dist_idx.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
379            dist_idx.iter().take(t).map(|&(_, j)| j).collect()
380        })
381        .collect()
382}
383
384// ─────────────────────────────────────────────────────────────────────────────
385// DE offspring generation
386// ─────────────────────────────────────────────────────────────────────────────
387
388/// Differential evolution offspring (DE/rand/1 + binomial crossover).
389///
390/// Selects three distinct random individuals from `pool` indices, applies
391/// the DE mutation y = x_r1 + F*(x_r2 - x_r3), and performs binomial
392/// crossover with rate CR = 0.5.  Result is clamped to `bounds`.
393fn de_offspring(
394    population: &[Individual],
395    pool: &[usize],
396    bounds: &[(f64, f64)],
397    rng: &mut StdRng,
398) -> Vec<f64> {
399    let n_pool = pool.len();
400    let n_vars = bounds.len();
401
402    // Pick r1, r2, r3 distinct
403    let r1 = pool[rng.random_range(0..n_pool)];
404    let mut r2 = pool[rng.random_range(0..n_pool)];
405    let mut r3 = pool[rng.random_range(0..n_pool)];
406
407    // Retry up to 3 times to get distinct indices (best effort)
408    for _ in 0..3 {
409        if r2 != r1 {
410            break;
411        }
412        r2 = pool[rng.random_range(0..n_pool)];
413    }
414    for _ in 0..3 {
415        if r3 != r1 && r3 != r2 {
416            break;
417        }
418        r3 = pool[rng.random_range(0..n_pool)];
419    }
420
421    let x1 = &population[r1].genes;
422    let x2 = &population[r2].genes;
423    let x3 = &population[r3].genes;
424
425    // DE scale factor in [0.5, 0.9]
426    let f_scale = 0.5 + rng.random::<f64>() * 0.4;
427    // Crossover rate
428    let cr = 0.5;
429
430    // Mandatory crossover index
431    let j_rand = rng.random_range(0..n_vars);
432
433    let (lo_v, hi_v): (Vec<f64>, Vec<f64>) = bounds.iter().map(|&(lo, hi)| (lo, hi)).unzip();
434
435    (0..n_vars)
436        .map(|j| {
437            if j == j_rand || rng.random::<f64>() < cr {
438                (x1[j] + f_scale * (x2[j] - x3[j])).clamp(lo_v[j], hi_v[j])
439            } else {
440                x1[j]
441            }
442        })
443        .collect()
444}
445
446// ─────────────────────────────────────────────────────────────────────────────
447// Random initialisation helper
448// ─────────────────────────────────────────────────────────────────────────────
449
450fn random_genes(bounds: &[(f64, f64)], rng: &mut StdRng) -> Vec<f64> {
451    bounds
452        .iter()
453        .map(|&(lo, hi)| lo + rng.random::<f64>() * (hi - lo))
454        .collect()
455}
456
457// ─────────────────────────────────────────────────────────────────────────────
458// Tests
459// ─────────────────────────────────────────────────────────────────────────────
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    fn zdt1(x: &[f64]) -> Vec<f64> {
466        let f1 = x[0];
467        let g = 1.0 + 9.0 * x[1..].iter().sum::<f64>() / (x.len() - 1) as f64;
468        let f2 = g * (1.0 - (f1 / g).sqrt());
469        vec![f1, f2]
470    }
471
472    // ── Weight vector generation ─────────────────────────────────────────────
473
474    #[test]
475    fn test_weight_vectors_sum_to_one() {
476        let mut rng = StdRng::seed_from_u64(0);
477        let wvs = generate_weight_vectors(2, 10, &mut rng);
478        for w in &wvs {
479            let s: f64 = w.iter().sum();
480            assert!((s - 1.0).abs() < 1e-10, "weight vector sum = {s}");
481            assert_eq!(w.len(), 2);
482        }
483    }
484
485    #[test]
486    fn test_weight_vectors_3obj() {
487        let mut rng = StdRng::seed_from_u64(1);
488        let wvs = generate_weight_vectors(3, 15, &mut rng);
489        for w in &wvs {
490            let s: f64 = w.iter().sum();
491            assert!((s - 1.0).abs() < 1e-10);
492            assert_eq!(w.len(), 3);
493            for &x in w {
494                assert!(x >= 0.0 && x <= 1.0);
495            }
496        }
497    }
498
499    // ── Neighbourhood ────────────────────────────────────────────────────────
500
501    #[test]
502    fn test_neighborhood_includes_self() {
503        let mut rng = StdRng::seed_from_u64(0);
504        let wvs = generate_weight_vectors(2, 10, &mut rng);
505        let nb = build_neighborhood(&wvs, 3);
506        for (i, n) in nb.iter().enumerate() {
507            assert!(n.contains(&i), "neighbourhood of {i} should contain itself");
508        }
509    }
510
511    #[test]
512    fn test_neighborhood_size() {
513        let mut rng = StdRng::seed_from_u64(0);
514        let wvs = generate_weight_vectors(2, 10, &mut rng);
515        let t = 4;
516        let nb = build_neighborhood(&wvs, t);
517        for n in &nb {
518            assert_eq!(n.len(), t);
519        }
520    }
521
522    // ── Tchebycheff scalarization ────────────────────────────────────────────
523
524    #[test]
525    fn test_tchebycheff_basic() {
526        let f = vec![1.0, 2.0];
527        let w = vec![0.5, 0.5];
528        let z = vec![0.0, 0.0];
529        let val = tchebycheff_scalarization(&f, &w, &z);
530        // max(0.5*1, 0.5*2) = max(0.5, 1.0) = 1.0
531        assert!((val - 1.0).abs() < 1e-10, "Expected 1.0, got {val}");
532    }
533
534    #[test]
535    fn test_tchebycheff_with_ideal_shift() {
536        let f = vec![3.0, 3.0];
537        let w = vec![0.5, 0.5];
538        let z = vec![1.0, 1.0];
539        let val = tchebycheff_scalarization(&f, &w, &z);
540        // max(0.5*2, 0.5*2) = 1.0
541        assert!((val - 1.0).abs() < 1e-10);
542    }
543
544    // ── MOEA/D on ZDT1 ──────────────────────────────────────────────────────
545
546    #[test]
547    fn test_moead_returns_pareto_front() {
548        let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 5];
549        let mut cfg = MoeadConfig::default();
550        cfg.population_size = 20;
551        cfg.n_generations = 10;
552        cfg.n_objectives = 2;
553        cfg.seed = 1;
554
555        let result = moead(&bounds, zdt1, cfg).expect("moead should succeed");
556        assert!(!result.pareto_front.is_empty());
557    }
558
559    #[test]
560    fn test_moead_weight_vectors_returned() {
561        let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 3];
562        let mut cfg = MoeadConfig::default();
563        cfg.population_size = 10;
564        cfg.n_generations = 5;
565        cfg.n_objectives = 2;
566
567        let result = moead(&bounds, zdt1, cfg).expect("failed to create result");
568        assert!(!result.weight_vectors.is_empty());
569        for w in &result.weight_vectors {
570            assert_eq!(w.len(), 2);
571            let s: f64 = w.iter().sum();
572            assert!((s - 1.0).abs() < 1e-10, "weight sum = {s}");
573        }
574    }
575
576    #[test]
577    fn test_moead_pareto_front_non_dominated() {
578        let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 5];
579        let mut cfg = MoeadConfig::default();
580        cfg.population_size = 20;
581        cfg.n_generations = 20;
582        cfg.n_objectives = 2;
583        cfg.seed = 42;
584
585        let result = moead(&bounds, zdt1, cfg).expect("failed to create result");
586
587        let front = &result.pareto_front;
588        for i in 0..front.len() {
589            for j in 0..front.len() {
590                if i != j {
591                    assert!(
592                        !dominates(&front[i].objectives, &front[j].objectives),
593                        "front[{i}] dominates front[{j}] in MOEA/D result"
594                    );
595                }
596            }
597        }
598    }
599
600    #[test]
601    fn test_moead_bounds_respected() {
602        let bounds = vec![(0.2, 0.8); 3];
603        let mut cfg = MoeadConfig::default();
604        cfg.population_size = 10;
605        cfg.n_generations = 5;
606        cfg.n_objectives = 2;
607
608        let result =
609            moead(&bounds, |x| vec![x[0], 1.0 - x[0]], cfg).expect("failed to create result");
610
611        for ind in &result.pareto_front {
612            for (i, &g) in ind.genes.iter().enumerate() {
613                assert!(
614                    g >= bounds[i].0 - 1e-9 && g <= bounds[i].1 + 1e-9,
615                    "gene[{i}]={g} outside bounds"
616                );
617            }
618        }
619    }
620
621    #[test]
622    fn test_moead_invalid_input() {
623        // Empty bounds
624        let result = moead(&[], |x| vec![x[0]], MoeadConfig::default());
625        assert!(result.is_err());
626
627        // Bad bound interval
628        let result = moead(&[(1.0, 0.0)], |x| vec![x[0]], MoeadConfig::default());
629        assert!(result.is_err());
630
631        // Too few objectives
632        let mut cfg = MoeadConfig::default();
633        cfg.n_objectives = 1;
634        let result = moead(&[(0.0, 1.0)], |x| vec![x[0]], cfg);
635        assert!(result.is_err());
636    }
637
638    #[test]
639    fn test_moead_generation_count() {
640        let bounds = vec![(0.0, 1.0); 3];
641        let mut cfg = MoeadConfig::default();
642        cfg.population_size = 10;
643        cfg.n_generations = 7;
644        cfg.n_objectives = 2;
645
646        let result = moead(&bounds, zdt1, cfg).expect("failed to create result");
647        assert_eq!(result.n_generations, 7);
648    }
649
650    #[test]
651    fn test_moead_diverse_objectives() {
652        // With sufficient generations, MOEA/D should find diverse coverage.
653        let bounds: Vec<(f64, f64)> = vec![(0.0, 1.0); 6];
654        let mut cfg = MoeadConfig::default();
655        cfg.population_size = 30;
656        cfg.n_generations = 30;
657        cfg.n_objectives = 2;
658        cfg.seed = 7;
659
660        let result = moead(&bounds, zdt1, cfg).expect("failed to create result");
661
662        // At least two distinct solutions must exist for a non-trivial problem
663        assert!(result.pareto_front.len() >= 2);
664
665        // Check all objectives are in plausible range for ZDT1
666        for ind in &result.pareto_front {
667            assert!(ind.objectives[0] >= 0.0, "f1 must be >= 0");
668            assert!(ind.objectives[1] >= 0.0, "f2 must be >= 0");
669        }
670    }
671}