Skip to main content

optirs_nas/multi_objective/
moead.rs

1//! MOEA/D — decomposition-based multi-objective optimizer (Zhang & Li, IEEE TEVC
2//! 2007), implemented for real.
3//!
4//! The algorithm decomposes the multi-objective problem into `N` single-objective
5//! subproblems, one per Das-Dennis weight vector, and keeps one current solution
6//! per subproblem. Each subproblem is improved only from the solutions of its
7//! neighbouring subproblems, which is what makes the population spread evenly
8//! along the front without an explicit diversity operator:
9//!
10//! 1. **Decomposition** — [`super::decomposition::das_dennis_weights`] builds a
11//!    uniform simplex lattice of weight vectors; the number of subproblems *is*
12//!    the population size.
13//! 2. **Neighbourhoods** — `B(i)` is the `neighborhood_size` weight vectors
14//!    closest to `w_i` ([`super::decomposition::weight_neighborhoods`]).
15//! 3. **Ideal point** — `z*` tracks the best value seen per objective and is what
16//!    every scalarization is measured against.
17//! 4. **Reproduction** — parents are drawn from `B(i)` (with probability
18//!    [`NEIGHBORHOOD_SELECTION_PROB`], otherwise from the whole population) and
19//!    recombined by the shared architecture operators.
20//! 5. **Scalarized replacement** — an evaluated offspring replaces up to
21//!    [`MAX_REPLACEMENTS`] neighbouring subproblem solutions, each only if it is
22//!    genuinely better *for that subproblem's* scalarization.
23//! 6. **External archive** — the non-dominated solutions found across the whole
24//!    run, which is what is reported as the Pareto front.
25//!
26//! Before this implementation existed, every fallible method here returned
27//! `NotImplemented` (and before *that*, `Ok(())` with an empty front, so a search
28//! configured for MOEA/D ran to completion and reported nothing). The type
29//! scaffolding — including [`DecompositionMethod`] — is unchanged; it is now
30//! backed by the algorithm it describes.
31
32use crate::error::{OptimError, Result};
33use crate::nas_engine::{MultiObjectiveConfig, OptimizerArchitecture, SearchResult};
34use scirs2_core::numeric::Float;
35use scirs2_core::random::Random;
36use std::collections::{HashMap, VecDeque};
37use std::fmt::Debug;
38
39use super::core::{
40    objective_directions, objective_vector_for_result, CreationMethod, Individual,
41    MultiObjectiveOptimizer, MultiObjectiveStatistics, ObjectiveBounds, ParetoFront,
42    ParetoSolution, SolutionMetadata,
43};
44use super::decomposition::{
45    das_dennis_weights, divisions_for_at_least, scalarize, update_ideal_point,
46    weight_neighborhoods, Scalarization,
47};
48use super::hypervolume::{
49    derive_reference_point, hypervolume_minimization, normalize_front_for_minimization,
50    normalize_reference_for_minimization,
51};
52use super::metrics;
53use super::operators;
54
55/// Default number of subproblems requested when the caller does not say. Kept
56/// small on purpose: in NAS every subproblem solution costs a full architecture
57/// evaluation, so the canonical `N = 100` of the MOEA/D papers would be an
58/// unreasonable default here. The actual count is the smallest Das-Dennis lattice
59/// that reaches this target.
60pub const DEFAULT_SUBPROBLEM_TARGET: usize = 20;
61
62/// Default neighbourhood size `T`.
63pub const DEFAULT_NEIGHBORHOOD_SIZE: usize = 5;
64
65/// Probability that both parents are drawn from the subproblem's neighbourhood
66/// rather than the whole population (`delta` in MOEA/D-DE).
67pub const NEIGHBORHOOD_SELECTION_PROB: f64 = 0.9;
68
69/// Maximum number of neighbouring subproblems a single offspring may replace
70/// (`nr` in MOEA/D-DE). Bounding it is what stops one good solution from taking
71/// over the whole population.
72pub const MAX_REPLACEMENTS: usize = 2;
73
74/// Default bound on the external non-dominated archive.
75pub const DEFAULT_ARCHIVE_CAPACITY: usize = 100;
76
77/// Per-component-position probability of a structural mutation.
78const MOEAD_COMPONENT_MUTATION_RATE: f64 = 0.2;
79
80/// Per-parameter probability of a numeric mutation.
81const MOEAD_PARAMETER_MUTATION_RATE: f64 = 0.3;
82
83/// Probability that reproduction recombines two parents rather than copying one.
84const MOEAD_CROSSOVER_PROB: f64 = 0.9;
85
86/// Probability that an offspring is mutated.
87const MOEAD_MUTATION_PROB: f64 = 0.3;
88
89/// Decomposition methods for MOEA/D
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum DecompositionMethod {
92    /// Weighted sum
93    WeightedSum,
94    /// Tchebycheff
95    Tchebycheff,
96    /// Penalty-based boundary intersection
97    PBI,
98    /// Achievement scalarizing function
99    ASF,
100}
101
102impl DecompositionMethod {
103    /// The scalarization this method is implemented by.
104    pub fn scalarization(self) -> Scalarization {
105        match self {
106            DecompositionMethod::WeightedSum => Scalarization::WeightedSum,
107            DecompositionMethod::Tchebycheff => Scalarization::Tchebycheff,
108            DecompositionMethod::PBI => Scalarization::PenaltyBoundaryIntersection,
109            DecompositionMethod::ASF => Scalarization::AchievementScalarizing,
110        }
111    }
112}
113
114/// MOEA/D implementation
115pub struct MOEADOptimizer<T: Float + Debug + Send + Sync + 'static> {
116    /// Algorithm configuration
117    pub(super) config: MultiObjectiveConfig<T>,
118    /// Weight vectors, one per subproblem (Das-Dennis lattice)
119    pub(super) weight_vectors: Vec<Vec<T>>,
120    /// Current solution of each subproblem; an individual with an empty objective
121    /// vector is a subproblem that has not been solved yet.
122    pub(super) population: Vec<Individual<T>>,
123    /// Neighbor indices for each subproblem
124    pub(super) neighbors: Vec<Vec<usize>>,
125    /// Current Pareto front (the external archive, published)
126    pub(super) pareto_front: ParetoFront<T>,
127    /// Ideal point `z*`, in **minimization** space
128    pub(super) ideal_point: Vec<T>,
129    /// Decomposition method
130    pub(super) decomposition: DecompositionMethod,
131    /// Neighborhood size
132    pub(super) neighborhood_size: usize,
133    /// Generation counter
134    pub(super) generation: usize,
135    /// Statistics
136    pub(super) statistics: MultiObjectiveStatistics<T>,
137    /// Number of divisions of the Das-Dennis lattice actually used
138    pub(super) divisions: usize,
139    /// Requested number of subproblems (the lattice may be slightly larger)
140    pub(super) subproblem_target: usize,
141    /// External non-dominated archive
142    pub(super) archive: Vec<Individual<T>>,
143    /// Bound on the archive size
144    pub(super) archive_capacity: usize,
145    /// Which subproblem each generated architecture was created for, so an
146    /// evaluated result is compared against the subproblem it was meant to
147    /// improve rather than one picked by position.
148    pub(super) subproblem_of: HashMap<String, usize>,
149    /// Insertion order of `subproblem_of`, used to bound it.
150    pub(super) subproblem_order: VecDeque<String>,
151    /// Random number generator
152    pub(super) rng: Random<scirs2_core::random::rngs::StdRng>,
153    /// Latched hypervolume reference point, in **raw** objective space
154    pub(super) hypervolume_reference: Option<Vec<T>>,
155    /// Hypervolume at the previous front update, for the `convergence` metric
156    pub(super) previous_hypervolume: Option<T>,
157    /// Replacements performed during the most recent update
158    pub(super) last_replacements: usize,
159}
160
161impl<T: Float + Debug + Default + Clone + Send + Sync + 'static> MOEADOptimizer<T> {
162    /// Construct MOEA/D with the default subproblem target, Tchebycheff
163    /// decomposition and an OS-entropy seed.
164    ///
165    /// The optimizer is fully usable after construction only once
166    /// [`MultiObjectiveOptimizer::initialize`] has built the lattice; `new` stores
167    /// the configuration and immediately initializes from it when it already
168    /// carries objectives, so a caller that never calls `initialize` still gets a
169    /// working optimizer rather than a silent empty one.
170    pub fn new(config: MultiObjectiveConfig<T>) -> Result<Self> {
171        Self::with_settings(
172            config,
173            DEFAULT_SUBPROBLEM_TARGET,
174            DecompositionMethod::Tchebycheff,
175            DEFAULT_NEIGHBORHOOD_SIZE,
176            scirs2_core::random::random::<u64>(),
177        )
178    }
179
180    /// Construct a fully reproducible MOEA/D.
181    pub fn with_seed(config: MultiObjectiveConfig<T>, seed: u64) -> Result<Self> {
182        Self::with_settings(
183            config,
184            DEFAULT_SUBPROBLEM_TARGET,
185            DecompositionMethod::Tchebycheff,
186            DEFAULT_NEIGHBORHOOD_SIZE,
187            seed,
188        )
189    }
190
191    /// Construct MOEA/D with every algorithm parameter given explicitly.
192    pub fn with_settings(
193        config: MultiObjectiveConfig<T>,
194        subproblem_target: usize,
195        decomposition: DecompositionMethod,
196        neighborhood_size: usize,
197        seed: u64,
198    ) -> Result<Self> {
199        let mut optimizer = Self {
200            config: config.clone(),
201            weight_vectors: Vec::new(),
202            population: Vec::new(),
203            neighbors: Vec::new(),
204            pareto_front: ParetoFront::default(),
205            ideal_point: Vec::new(),
206            decomposition,
207            neighborhood_size: neighborhood_size.max(1),
208            generation: 0,
209            statistics: MultiObjectiveStatistics::default(),
210            divisions: 0,
211            subproblem_target: subproblem_target.max(1),
212            archive: Vec::new(),
213            archive_capacity: DEFAULT_ARCHIVE_CAPACITY,
214            subproblem_of: HashMap::new(),
215            subproblem_order: VecDeque::new(),
216            rng: Random::seed(seed),
217            hypervolume_reference: None,
218            previous_hypervolume: None,
219            last_replacements: 0,
220        };
221        if !config.objectives.is_empty() {
222            optimizer.build_decomposition(&config)?;
223        }
224        Ok(optimizer)
225    }
226
227    /// The decomposition method in use.
228    pub fn decomposition(&self) -> DecompositionMethod {
229        self.decomposition
230    }
231
232    /// Switch the scalarization. Existing subproblem solutions are kept: they are
233    /// still valid solutions, they are simply compared differently from now on.
234    pub fn set_decomposition(&mut self, decomposition: DecompositionMethod) {
235        self.decomposition = decomposition;
236    }
237
238    /// Number of subproblems (= weight vectors = population size).
239    pub fn subproblem_count(&self) -> usize {
240        self.weight_vectors.len()
241    }
242
243    /// Weight vector of subproblem `index`, if it exists.
244    pub fn weight_vector(&self, index: usize) -> Option<&[T]> {
245        self.weight_vectors
246            .get(index)
247            .map(|weight| weight.as_slice())
248    }
249
250    /// The neighbourhood `B(index)`.
251    pub fn neighborhood(&self, index: usize) -> Option<&[usize]> {
252        self.neighbors.get(index).map(|list| list.as_slice())
253    }
254
255    /// The current ideal point `z*`, in minimization space.
256    pub fn ideal_point(&self) -> &[T] {
257        &self.ideal_point
258    }
259
260    /// Bound on the external archive.
261    pub fn set_archive_capacity(&mut self, capacity: usize) {
262        self.archive_capacity = capacity.max(1);
263        self.truncate_archive();
264    }
265
266    /// Pin the hypervolume reference point (raw objective space, one entry per
267    /// configured objective).
268    pub fn set_hypervolume_reference(&mut self, reference: Vec<T>) -> Result<()> {
269        if reference.len() != self.config.objectives.len() {
270            return Err(OptimError::InvalidConfig(format!(
271                "hypervolume reference has {} entries but {} objectives are configured",
272                reference.len(),
273                self.config.objectives.len()
274            )));
275        }
276        self.hypervolume_reference = Some(reference);
277        Ok(())
278    }
279
280    /// Build the weight lattice, neighbourhoods and initial (unevaluated)
281    /// subproblem solutions from `config`.
282    fn build_decomposition(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
283        let num_objectives = config.objectives.len();
284        if num_objectives == 0 {
285            return Err(OptimError::InvalidConfig(
286                "MOEA/D needs at least one configured objective to decompose".to_string(),
287            ));
288        }
289        let (divisions, lattice_size) =
290            divisions_for_at_least(num_objectives, self.subproblem_target).ok_or_else(|| {
291                OptimError::InvalidConfig(format!(
292                    "no Das-Dennis lattice with at least {} vectors exists for {} objectives",
293                    self.subproblem_target, num_objectives
294                ))
295            })?;
296        self.divisions = divisions;
297        self.weight_vectors = das_dennis_weights::<T>(num_objectives, divisions);
298        if self.weight_vectors.len() != lattice_size {
299            return Err(OptimError::OptimizationError(format!(
300                "weight lattice size {} disagrees with the computed {}",
301                self.weight_vectors.len(),
302                lattice_size
303            )));
304        }
305        self.neighbors = weight_neighborhoods(&self.weight_vectors, self.neighborhood_size);
306        self.ideal_point = vec![T::infinity(); num_objectives];
307        self.population.clear();
308        self.subproblem_of.clear();
309        self.subproblem_order.clear();
310        for index in 0..self.weight_vectors.len() {
311            let architecture = operators::sample_architecture::<T>(&mut self.rng, "arch");
312            let id = architecture.architecture_id.clone();
313            self.population.push(Individual {
314                architecture,
315                objectives: Vec::new(),
316                constraints: Vec::new(),
317                rank: 0,
318                crowding_distance: T::zero(),
319                fitness: T::zero(),
320                id: id.clone(),
321            });
322            self.remember_subproblem(id, index);
323        }
324        Ok(())
325    }
326
327    /// Record that `architecture_id` was generated for subproblem `index`, keeping
328    /// the map bounded so a long run cannot grow it without limit.
329    fn remember_subproblem(&mut self, architecture_id: String, index: usize) {
330        if architecture_id.is_empty() {
331            return;
332        }
333        let capacity = (self.weight_vectors.len().max(1)).saturating_mul(4);
334        if self
335            .subproblem_of
336            .insert(architecture_id.clone(), index)
337            .is_none()
338        {
339            self.subproblem_order.push_back(architecture_id);
340        }
341        while self.subproblem_order.len() > capacity {
342            if let Some(oldest) = self.subproblem_order.pop_front() {
343                self.subproblem_of.remove(&oldest);
344            }
345        }
346    }
347
348    /// Objective vector of `result` mapped into the pure-minimization convention.
349    fn minimization_objectives(&self, result: &SearchResult<T>) -> Vec<T> {
350        let raw = objective_vector_for_result(&self.config.objectives, result);
351        self.to_minimization(&raw)
352    }
353
354    /// Map a raw objective vector into minimization space.
355    fn to_minimization(&self, raw: &[T]) -> Vec<T> {
356        let directions = objective_directions(&self.config.objectives);
357        normalize_front_for_minimization(&[raw.to_vec()], &directions)
358            .into_iter()
359            .next()
360            .unwrap_or_default()
361    }
362
363    /// The ideal point in a form a scalarization can actually be measured
364    /// against.
365    ///
366    /// [`Self::ideal_point`] starts as `+inf` per objective — the standard "nothing
367    /// observed yet" marker — but subtracting an infinite reference makes every
368    /// scalarized cost infinite and every comparison meaningless, so any
369    /// non-finite entry is reported as `0` here. `reference` lets the caller fold
370    /// in the set it is about to rank, which is what makes ranking work before any
371    /// result has been absorbed.
372    fn effective_ideal(&self, reference: &[Vec<T>]) -> Vec<T> {
373        let num_objectives = self.config.objectives.len();
374        let mut ideal = self.ideal_point.clone();
375        if ideal.len() < num_objectives {
376            ideal.resize(num_objectives, T::infinity());
377        }
378        for point in reference {
379            update_ideal_point(&mut ideal, point);
380        }
381        for value in ideal.iter_mut() {
382            if !value.is_finite() {
383                *value = T::zero();
384            }
385        }
386        ideal
387    }
388
389    /// Scalarized cost of a minimization-space objective vector for subproblem
390    /// `index`, measured against `ideal`. Lower is better.
391    fn subproblem_cost(&self, index: usize, minimized: &[T], ideal: &[T]) -> Option<T> {
392        let weight = self.weight_vectors.get(index)?;
393        Some(scalarize(
394            self.decomposition.scalarization(),
395            minimized,
396            weight,
397            ideal,
398        ))
399    }
400
401    /// The subproblem an unassigned solution belongs to: the one whose
402    /// scalarization it is best for. This is the honest fallback for a result
403    /// whose architecture MOEA/D did not generate (an externally supplied
404    /// candidate), and it is what makes the algorithm usable inside an engine that
405    /// evaluates architectures from several sources.
406    fn best_fit_subproblem(&self, minimized: &[T], ideal: &[T]) -> Option<usize> {
407        let mut best_index = None;
408        let mut best_cost = T::infinity();
409        for index in 0..self.weight_vectors.len() {
410            let Some(cost) = self.subproblem_cost(index, minimized, ideal) else {
411                continue;
412            };
413            if cost < best_cost {
414                best_cost = cost;
415                best_index = Some(index);
416            }
417        }
418        best_index
419    }
420
421    /// Whether `individual` carries a complete objective vector.
422    fn is_evaluated(&self, individual: &Individual<T>) -> bool {
423        !self.config.objectives.is_empty()
424            && individual.objectives.len() == self.config.objectives.len()
425    }
426
427    /// Does `a` dominate `b`? Both vectors are in minimization space.
428    fn dominates(a: &[T], b: &[T]) -> bool {
429        if a.len() != b.len() || a.is_empty() {
430            return false;
431        }
432        let mut strictly_better = false;
433        for (left, right) in a.iter().zip(b.iter()) {
434            if *left > *right {
435                return false;
436            }
437            if *left < *right {
438                strictly_better = true;
439            }
440        }
441        strictly_better
442    }
443
444    /// Offer `candidate` to the external archive, keeping only the non-dominated
445    /// set. Returns `true` when the candidate was accepted.
446    ///
447    /// The decision is taken over the whole archive *before* anything is removed:
448    /// a scan that dropped members as it went and bailed out halfway through on
449    /// finding a dominating member would silently lose everything it had not yet
450    /// looked at.
451    fn offer_to_archive(&mut self, candidate: &Individual<T>, minimized: &[T]) -> bool {
452        if minimized.is_empty() {
453            return false;
454        }
455        let member_objectives: Vec<Vec<T>> = self
456            .archive
457            .iter()
458            .map(|member| self.to_minimization(&member.objectives))
459            .collect();
460
461        let already_covered = member_objectives
462            .iter()
463            .any(|member| Self::dominates(member, minimized) || member.as_slice() == minimized);
464        if already_covered {
465            return false;
466        }
467
468        let candidate_id = candidate.architecture.architecture_id.as_str();
469        let mut index = 0usize;
470        self.archive.retain(|member| {
471            let dominated = Self::dominates(minimized, &member_objectives[index]);
472            // Identity is by architecture id: re-evaluating the same architecture
473            // must refresh its entry, not duplicate it.
474            let superseded =
475                !candidate_id.is_empty() && member.architecture.architecture_id == candidate_id;
476            index += 1;
477            !dominated && !superseded
478        });
479        self.archive.push(candidate.clone());
480        self.truncate_archive();
481        true
482    }
483
484    /// Bound the archive by repeatedly removing the member with the smallest
485    /// nearest-neighbour distance in normalized objective space (density-based
486    /// truncation, as in SPEA2). The removed member is always the one sitting in
487    /// the most crowded region, so the extremes of the front survive.
488    fn truncate_archive(&mut self) {
489        while self.archive.len() > self.archive_capacity {
490            let points: Vec<Vec<T>> = self
491                .archive
492                .iter()
493                .map(|member| self.to_minimization(&member.objectives))
494                .collect();
495            let Some(victim) = most_crowded_index(&points) else {
496                break;
497            };
498            self.archive.remove(victim);
499        }
500    }
501
502    /// Rebuild [`Self::pareto_front`] from the archive, including real bounds and
503    /// front metrics.
504    fn publish_archive(&mut self) {
505        let solutions: Vec<ParetoSolution<T>> = self
506            .archive
507            .iter()
508            .map(|member| ParetoSolution {
509                architecture: member.architecture.clone(),
510                objectives: member.objectives.clone(),
511                constraint_violations: member.constraints.clone(),
512                rank: 0,
513                crowding_distance: member.crowding_distance,
514                metadata: SolutionMetadata {
515                    id: member.id.clone(),
516                    generation: self.generation,
517                    evaluation_count: self.statistics.total_evaluations,
518                    parents: Vec::new(),
519                    creation_method: creation_method_from_id(&member.architecture.architecture_id),
520                },
521            })
522            .collect();
523        self.pareto_front.solutions = solutions;
524        self.pareto_front.generation = self.generation;
525        self.pareto_front.last_updated = std::time::SystemTime::now();
526        self.update_objective_bounds();
527        self.update_front_metrics();
528    }
529
530    fn update_objective_bounds(&mut self) {
531        let Some(first) = self.pareto_front.solutions.first() else {
532            return;
533        };
534        let num_objectives = first.objectives.len();
535        let mut min_values = vec![T::infinity(); num_objectives];
536        let mut max_values = vec![T::neg_infinity(); num_objectives];
537        for solution in &self.pareto_front.solutions {
538            for (index, value) in solution.objectives.iter().enumerate() {
539                if index >= num_objectives {
540                    break;
541                }
542                if *value < min_values[index] {
543                    min_values[index] = *value;
544                }
545                if *value > max_values[index] {
546                    max_values[index] = *value;
547                }
548            }
549        }
550        self.pareto_front.objective_bounds = ObjectiveBounds {
551            min_values: min_values.clone(),
552            max_values: max_values.clone(),
553            ideal_point: min_values,
554            nadir_point: max_values,
555        };
556    }
557
558    /// The archive's objective vectors in minimization space.
559    fn front_in_minimization_space(&self) -> Vec<Vec<T>> {
560        let directions = objective_directions(&self.config.objectives);
561        let raw: Vec<Vec<T>> = self
562            .pareto_front
563            .solutions
564            .iter()
565            .map(|solution| solution.objectives.clone())
566            .collect();
567        normalize_front_for_minimization(&raw, &directions)
568    }
569
570    /// Exact hypervolume of the published front against the latched reference
571    /// point, derived from the first measured front when none was supplied.
572    fn calculate_hypervolume(&mut self) -> T {
573        let front = self.front_in_minimization_space();
574        if front.is_empty() {
575            return T::zero();
576        }
577        let directions = objective_directions(&self.config.objectives);
578        if self.hypervolume_reference.is_none() {
579            let Some(derived) = derive_reference_point(&front) else {
580                return T::zero();
581            };
582            // Derived in minimization space; stored back in raw space so the public
583            // setter and getter speak the caller's convention.
584            self.hypervolume_reference =
585                Some(normalize_reference_for_minimization(&derived, &directions));
586        }
587        let Some(raw_reference) = self.hypervolume_reference.as_ref() else {
588            return T::zero();
589        };
590        let reference = normalize_reference_for_minimization(raw_reference, &directions);
591        hypervolume_minimization(&front, &reference)
592    }
593
594    fn update_front_metrics(&mut self) {
595        let front = self.front_in_minimization_space();
596        let hypervolume = self.calculate_hypervolume();
597        let previous = self.previous_hypervolume;
598        self.previous_hypervolume = Some(hypervolume);
599
600        let directions = objective_directions(&self.config.objectives);
601        let reference = self
602            .hypervolume_reference
603            .as_ref()
604            .map(|raw| normalize_reference_for_minimization(raw, &directions))
605            .unwrap_or_default();
606        let population: Vec<Vec<T>> = normalize_front_for_minimization(
607            &self
608                .population
609                .iter()
610                .map(|individual| individual.objectives.clone())
611                .collect::<Vec<_>>(),
612            &directions,
613        );
614
615        self.pareto_front.metrics = metrics::front_metrics_in_minimization_space(
616            &front,
617            &population,
618            &reference,
619            hypervolume,
620            previous,
621        );
622        self.statistics.pareto_front_size = self.pareto_front.solutions.len();
623        if hypervolume > self.statistics.best_hypervolume {
624            self.statistics.best_hypervolume = hypervolume;
625        }
626        self.statistics.generation = self.generation;
627        self.statistics
628            .convergence_history
629            .push(self.pareto_front.metrics.convergence);
630        self.statistics
631            .diversity_history
632            .push(self.pareto_front.metrics.spread);
633        let solved = self
634            .population
635            .iter()
636            .filter(|individual| self.is_evaluated(individual))
637            .count();
638        self.statistics.algorithm_metrics.insert(
639            "subproblems".to_string(),
640            T::from(self.weight_vectors.len()).unwrap_or_else(T::zero),
641        );
642        self.statistics.algorithm_metrics.insert(
643            "solved_subproblems".to_string(),
644            T::from(solved).unwrap_or_else(T::zero),
645        );
646        self.statistics.algorithm_metrics.insert(
647            "archive_size".to_string(),
648            T::from(self.archive.len()).unwrap_or_else(T::zero),
649        );
650        self.statistics.algorithm_metrics.insert(
651            "replacements".to_string(),
652            T::from(self.last_replacements).unwrap_or_else(T::zero),
653        );
654    }
655
656    /// Number of subproblem solutions replaced by the most recent update.
657    pub fn last_replacements(&self) -> usize {
658        self.last_replacements
659    }
660
661    /// The external non-dominated archive.
662    pub fn archive(&self) -> &[Individual<T>] {
663        &self.archive
664    }
665
666    /// Rank `results` by decomposition and return the indices of the best `k`.
667    ///
668    /// Each result is assigned to the subproblem it is best for; the best result
669    /// per subproblem is taken first (in increasing scalarized cost), then any
670    /// remaining results in increasing cost for their own subproblem. Selecting one
671    /// per subproblem before doubling up is what preserves the spread MOEA/D exists
672    /// to maintain.
673    pub(crate) fn select_by_decomposition(
674        &self,
675        results: &[SearchResult<T>],
676        k: usize,
677    ) -> Vec<usize> {
678        if results.is_empty() || k == 0 || self.weight_vectors.is_empty() {
679            return Vec::new();
680        }
681        let minimized_results: Vec<Vec<T>> = results
682            .iter()
683            .map(|result| self.minimization_objectives(result))
684            .collect();
685        // Rank against the best point known *including* the batch being ranked, so
686        // a caller that ranks before any result has been absorbed still gets a
687        // meaningful ordering rather than a tie between infinities.
688        let ideal = self.effective_ideal(&minimized_results);
689
690        let mut scored: Vec<(usize, usize, T)> = Vec::with_capacity(results.len());
691        for (index, minimized) in minimized_results.iter().enumerate() {
692            let Some(subproblem) = self.best_fit_subproblem(minimized, &ideal) else {
693                continue;
694            };
695            let cost = self
696                .subproblem_cost(subproblem, minimized, &ideal)
697                .unwrap_or_else(T::infinity);
698            scored.push((index, subproblem, cost));
699        }
700        scored.sort_by(|a, b| {
701            a.2.partial_cmp(&b.2)
702                .unwrap_or(std::cmp::Ordering::Equal)
703                .then_with(|| a.0.cmp(&b.0))
704        });
705
706        let mut taken_subproblems = std::collections::HashSet::new();
707        let mut selected = Vec::with_capacity(k.min(scored.len()));
708        for (index, subproblem, _) in &scored {
709            if selected.len() >= k {
710                break;
711            }
712            if taken_subproblems.insert(*subproblem) {
713                selected.push(*index);
714            }
715        }
716        for (index, _, _) in &scored {
717            if selected.len() >= k {
718                break;
719            }
720            if !selected.contains(index) {
721                selected.push(*index);
722            }
723        }
724        selected
725    }
726
727    /// Mean pairwise Euclidean distance between the objective vectors of
728    /// `results` — a real diversity measurement for the engine adapter.
729    pub(crate) fn mean_objective_distance(&self, results: &[SearchResult<T>]) -> f64 {
730        let vectors: Vec<Vec<T>> = results
731            .iter()
732            .map(|result| objective_vector_for_result(&self.config.objectives, result))
733            .collect();
734        metrics::mean_pairwise_distance(&vectors)
735    }
736}
737
738/// Index of the most crowded point, using SPEA2's truncation rule: compare each
739/// point's distances to all others, sorted ascending, **lexicographically**; the
740/// point with the smallest such sequence sits in the densest region and is the one
741/// to drop.
742///
743/// Distances are measured in box-normalized objective space so no single
744/// objective's scale dominates. `None` for fewer than two points.
745///
746/// The lexicographic tie-break matters: comparing only the single nearest
747/// neighbour leaves every point of an evenly spaced front tied, so the first index
748/// wins by accident and repeated truncation eats the front from one end — the
749/// extreme solutions, which are exactly the ones a Pareto front must keep, are
750/// destroyed first. Under the full rule an interior point always compares smaller
751/// than a boundary point (its *second* neighbour is closer), so the extremes
752/// survive without needing to be special-cased.
753fn most_crowded_index<T: Float>(points: &[Vec<T>]) -> Option<usize> {
754    if points.len() < 2 {
755        return None;
756    }
757    let dims = points.iter().map(|point| point.len()).min()?;
758    if dims == 0 {
759        return None;
760    }
761    let mut min_values = vec![T::infinity(); dims];
762    let mut max_values = vec![T::neg_infinity(); dims];
763    for point in points {
764        for index in 0..dims {
765            if point[index] < min_values[index] {
766                min_values[index] = point[index];
767            }
768            if point[index] > max_values[index] {
769                max_values[index] = point[index];
770            }
771        }
772    }
773    let spans: Vec<T> = (0..dims)
774        .map(|index| {
775            let span = max_values[index] - min_values[index];
776            if span > T::zero() {
777                span
778            } else {
779                T::one()
780            }
781        })
782        .collect();
783
784    let sorted_distances = |i: usize| -> Vec<T> {
785        let point = &points[i];
786        let mut distances: Vec<T> = points
787            .iter()
788            .enumerate()
789            .filter(|(j, _)| *j != i)
790            .map(|(_, other)| {
791                let mut sum = T::zero();
792                for index in 0..dims {
793                    let diff = (point[index] - other[index]) / spans[index];
794                    sum = sum + diff * diff;
795                }
796                sum.sqrt()
797            })
798            .collect();
799        distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
800        distances
801    };
802
803    let mut worst_index = 0usize;
804    let mut worst_profile = sorted_distances(0);
805    for i in 1..points.len() {
806        let profile = sorted_distances(i);
807        let mut smaller = false;
808        for (candidate, incumbent) in profile.iter().zip(worst_profile.iter()) {
809            if *candidate < *incumbent {
810                smaller = true;
811                break;
812            }
813            if *candidate > *incumbent {
814                break;
815            }
816        }
817        if smaller {
818            worst_index = i;
819            worst_profile = profile;
820        }
821    }
822    Some(worst_index)
823}
824
825/// Recover how an architecture was created from the prefix the variation
826/// operators stamp onto its identifier.
827fn creation_method_from_id(architecture_id: &str) -> CreationMethod {
828    if architecture_id.starts_with("offspring_") {
829        CreationMethod::Crossover
830    } else if architecture_id.starts_with("mutant_") {
831        CreationMethod::Mutation
832    } else if architecture_id.starts_with("arch_") {
833        CreationMethod::RandomGeneration
834    } else {
835        CreationMethod::Custom
836    }
837}
838
839impl<
840        T: Float
841            + Debug
842            + Default
843            + Clone
844            + Send
845            + Sync
846            + std::fmt::Debug
847            + PartialOrd
848            + std::iter::Sum,
849    > MultiObjectiveOptimizer<T> for MOEADOptimizer<T>
850{
851    fn initialize(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
852        self.config = config.clone();
853        self.generation = 0;
854        self.statistics = MultiObjectiveStatistics::default();
855        self.archive.clear();
856        self.pareto_front = ParetoFront::default();
857        self.hypervolume_reference = None;
858        self.previous_hypervolume = None;
859        self.last_replacements = 0;
860        self.build_decomposition(config)
861    }
862
863    /// Absorb evaluated `results` through MOEA/D's scalarized replacement, update
864    /// the ideal point and the external archive, and publish the archive as the
865    /// Pareto front.
866    fn update_pareto_front(&mut self, results: &[SearchResult<T>]) -> Result<ParetoFront<T>> {
867        if self.weight_vectors.is_empty() {
868            return Err(OptimError::InvalidConfig(
869                "MOEA/D has no weight vectors; call initialize with a configuration \
870                 that declares at least one objective first"
871                    .to_string(),
872            ));
873        }
874        self.last_replacements = 0;
875        for result in results {
876            let raw = objective_vector_for_result(&self.config.objectives, result);
877            let minimized = self.to_minimization(&raw);
878            if minimized.len() != self.config.objectives.len() {
879                continue;
880            }
881            update_ideal_point(&mut self.ideal_point, &minimized);
882
883            let ideal = self.effective_ideal(&[]);
884            let architecture_id = result.architecture.architecture_id.clone();
885            let owner = self
886                .subproblem_of
887                .get(&architecture_id)
888                .copied()
889                .or_else(|| self.best_fit_subproblem(&minimized, &ideal));
890            let Some(owner) = owner else {
891                continue;
892            };
893            // The assignment has been consumed.
894            self.subproblem_of.remove(&architecture_id);
895
896            let candidate = Individual {
897                architecture: result.architecture.clone(),
898                objectives: raw,
899                constraints: Vec::new(),
900                rank: 0,
901                crowding_distance: T::zero(),
902                fitness: T::zero(),
903                id: if architecture_id.is_empty() {
904                    format!("moead_{}_{}", self.generation, owner)
905                } else {
906                    architecture_id
907                },
908            };
909
910            // Initialization: when the arriving result *is* the solution the owning
911            // subproblem is already holding and that subproblem has never been
912            // evaluated, this is MOEA/D's initial population evaluation — record it
913            // there directly. Routing it through the competitive neighbourhood
914            // replacement instead would let the shuffle spend the whole
915            // `MAX_REPLACEMENTS` budget on neighbours and leave the subproblem the
916            // solution was drawn for unsolved.
917            let owner_holds_candidate = self.population.get(owner).is_some_and(|incumbent| {
918                !candidate.architecture.architecture_id.is_empty()
919                    && incumbent.architecture.architecture_id
920                        == candidate.architecture.architecture_id
921                    && !self.is_evaluated(incumbent)
922            });
923            if owner_holds_candidate {
924                self.population[owner] = candidate.clone();
925                self.offer_to_archive(&candidate, &minimized);
926                continue;
927            }
928
929            // Scalarized replacement over a shuffled neighbourhood, bounded by
930            // MAX_REPLACEMENTS so one solution cannot flood the population.
931            let mut neighborhood = self
932                .neighbors
933                .get(owner)
934                .cloned()
935                .unwrap_or_else(|| vec![owner]);
936            for position in (1..neighborhood.len()).rev() {
937                let other = self.rng.gen_range(0..=position);
938                neighborhood.swap(position, other);
939            }
940            let mut replacements = 0usize;
941            for neighbor in neighborhood {
942                if replacements >= MAX_REPLACEMENTS {
943                    break;
944                }
945                let Some(incumbent) = self.population.get(neighbor) else {
946                    continue;
947                };
948                let accept = if self.is_evaluated(incumbent) {
949                    let incumbent_min = self.to_minimization(&incumbent.objectives);
950                    match (
951                        self.subproblem_cost(neighbor, &minimized, &ideal),
952                        self.subproblem_cost(neighbor, &incumbent_min, &ideal),
953                    ) {
954                        (Some(new_cost), Some(old_cost)) => new_cost < old_cost,
955                        _ => false,
956                    }
957                } else {
958                    // A subproblem with no solution at all accepts any evaluated
959                    // one: MOEA/D assumes an evaluated initial population, and this
960                    // is how that population gets filled when solutions arrive from
961                    // an external evaluator.
962                    true
963                };
964                if accept {
965                    self.population[neighbor] = candidate.clone();
966                    replacements += 1;
967                }
968            }
969            self.last_replacements += replacements;
970            self.offer_to_archive(&candidate, &minimized);
971        }
972        self.generation += 1;
973        self.statistics.total_evaluations += results.len();
974        self.publish_archive();
975        Ok(self.pareto_front.clone())
976    }
977
978    fn get_pareto_front(&self) -> &ParetoFront<T> {
979        &self.pareto_front
980    }
981
982    /// Produce one offspring per subproblem by MOEA/D reproduction.
983    ///
984    /// Parents come from the subproblem's neighbourhood with probability
985    /// [`NEIGHBORHOOD_SELECTION_PROB`] and from the whole population otherwise.
986    /// Architectures passed in by the caller join the global parent pool, so an
987    /// engine that maintains its own candidate population contributes material
988    /// instead of being ignored. Every offspring is registered against the
989    /// subproblem it was generated for, which is what lets
990    /// [`MultiObjectiveOptimizer::update_pareto_front`] compare it against the
991    /// right scalarization when its evaluation comes back.
992    fn select_candidates(
993        &mut self,
994        population: &[OptimizerArchitecture<T>],
995        _objectives: &[T],
996    ) -> Result<Vec<OptimizerArchitecture<T>>> {
997        if self.weight_vectors.is_empty() {
998            return Err(OptimError::InvalidConfig(
999                "MOEA/D has no weight vectors; call initialize with a configuration \
1000                 that declares at least one objective first"
1001                    .to_string(),
1002            ));
1003        }
1004        let subproblems = self.weight_vectors.len();
1005        let mut offspring = Vec::with_capacity(subproblems);
1006        for index in 0..subproblems {
1007            let use_neighborhood = self.rng.gen_range(0.0..1.0) < NEIGHBORHOOD_SELECTION_PROB;
1008            let pool: Vec<usize> = if use_neighborhood {
1009                self.neighbors
1010                    .get(index)
1011                    .cloned()
1012                    .unwrap_or_else(|| vec![index])
1013            } else {
1014                (0..self.population.len()).collect()
1015            };
1016            if pool.is_empty() {
1017                continue;
1018            }
1019            let first = pool[self.rng.gen_range(0..pool.len())];
1020            let second = pool[self.rng.gen_range(0..pool.len())];
1021            let parent1 = self
1022                .population
1023                .get(first)
1024                .map(|individual| individual.architecture.clone());
1025            // Externally supplied architectures join the pool as second parents.
1026            let parent2 = if !population.is_empty() && !use_neighborhood {
1027                Some(population[self.rng.gen_range(0..population.len())].clone())
1028            } else {
1029                self.population
1030                    .get(second)
1031                    .map(|individual| individual.architecture.clone())
1032            };
1033            let (Some(parent1), Some(parent2)) = (parent1, parent2) else {
1034                continue;
1035            };
1036
1037            let mut child = if self.rng.gen_range(0.0..1.0) < MOEAD_CROSSOVER_PROB {
1038                operators::crossover_architectures(&mut self.rng, &parent1, &parent2)
1039            } else {
1040                parent1
1041            };
1042            if self.rng.gen_range(0.0..1.0) < MOEAD_MUTATION_PROB {
1043                operators::mutate_architecture(
1044                    &mut self.rng,
1045                    &mut child,
1046                    MOEAD_COMPONENT_MUTATION_RATE,
1047                    MOEAD_PARAMETER_MUTATION_RATE,
1048                );
1049            }
1050            self.remember_subproblem(child.architecture_id.clone(), index);
1051            offspring.push(child);
1052        }
1053        Ok(offspring)
1054    }
1055
1056    fn name(&self) -> &str {
1057        "MOEA/D"
1058    }
1059
1060    fn get_statistics(&self) -> MultiObjectiveStatistics<T> {
1061        self.statistics.clone()
1062    }
1063}