Skip to main content

scirs2_optimize/constrained/
epsilon_constraint.rs

1//! Epsilon-constraint method for multi-objective optimization.
2//!
3//! The epsilon-constraint method systematically converts a multi-objective
4//! problem into a series of single-objective constrained subproblems by
5//! optimizing one objective while constraining all others to lie within
6//! ε-bounds.  By varying the ε-values, the complete Pareto front can be
7//! approximated.
8//!
9//! # Algorithm outline
10//!
11//! Given a problem with `M` objectives `f_1(x), f_2(x), ..., f_M(x)`:
12//!
13//! 1. Choose one primary objective `k` to minimize.
14//! 2. Constrain all other objectives: `f_i(x) ≤ ε_i` for `i ≠ k`.
15//! 3. Solve the resulting single-objective constrained problem.
16//! 4. Systematically vary `ε` to explore different Pareto trade-offs.
17//!
18//! # References
19//!
20//! - Haimes, Y.V., Lasdon, L.S., & Wismer, D.A. (1971). On a bicriterion
21//!   formulation of the problems of integrated system identification and system
22//!   optimization. *IEEE Transactions on Systems, Man, and Cybernetics*, 1(3),
23//!   296–297.
24//! - Chankong, V. & Haimes, Y.V. (1983). Multiobjective Decision Making:
25//!   Theory and Methodology. Elsevier.
26
27use crate::error::{OptimizeError, OptimizeResult};
28
29// ─────────────────────────────────────────────────────────────────────────────
30// EpsilonConstraint struct
31// ─────────────────────────────────────────────────────────────────────────────
32
33/// Configuration for the epsilon-constraint method.
34///
35/// Specifies which objective to optimize and the upper bounds (ε-values) for
36/// all remaining objectives.
37///
38/// # Examples
39/// ```
40/// use scirs2_optimize::constrained::epsilon_constraint::EpsilonConstraint;
41///
42/// // 2-objective problem: minimize f_0 subject to f_1 ≤ ε_1
43/// let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.5]);
44/// assert_eq!(ec.primary_objective(), 0);
45/// assert_eq!(ec.epsilon_for(1), Some(0.5));
46/// ```
47#[derive(Debug, Clone)]
48pub struct EpsilonConstraint {
49    /// Index of the primary objective to minimize (0-based).
50    primary_idx: usize,
51    /// Upper bounds for each objective.
52    ///
53    /// `epsilon[i]` is the ε-bound for objective `i`.
54    /// For the primary objective (`i == primary_idx`), this value is ignored;
55    /// set it to `f64::INFINITY` for clarity.
56    epsilon: Vec<f64>,
57}
58
59impl EpsilonConstraint {
60    /// Create a new epsilon-constraint specification.
61    ///
62    /// # Arguments
63    /// * `primary_idx` — Index (0-based) of the objective to minimize.
64    /// * `epsilon`     — Upper bounds for all objectives; `epsilon[primary_idx]`
65    ///   is ignored in constraint evaluation.
66    ///
67    /// # Errors
68    /// Returns an error if `primary_idx >= epsilon.len()` or `epsilon` is empty.
69    pub fn new_checked(primary_idx: usize, epsilon: Vec<f64>) -> OptimizeResult<Self> {
70        if epsilon.is_empty() {
71            return Err(OptimizeError::InvalidInput(
72                "epsilon vector must be non-empty".to_string(),
73            ));
74        }
75        if primary_idx >= epsilon.len() {
76            return Err(OptimizeError::InvalidInput(format!(
77                "primary_idx={primary_idx} must be < epsilon.len()={}",
78                epsilon.len()
79            )));
80        }
81        Ok(Self {
82            primary_idx,
83            epsilon,
84        })
85    }
86
87    /// Create a new epsilon-constraint specification (panics on invalid input;
88    /// use [`new_checked`] for fallible construction).
89    ///
90    /// [`new_checked`]: Self::new_checked
91    pub fn new(primary_idx: usize, epsilon: Vec<f64>) -> Self {
92        Self {
93            primary_idx,
94            epsilon,
95        }
96    }
97
98    /// Index of the primary objective to minimize.
99    pub fn primary_objective(&self) -> usize {
100        self.primary_idx
101    }
102
103    /// Number of objectives.
104    pub fn n_objectives(&self) -> usize {
105        self.epsilon.len()
106    }
107
108    /// Return the ε-bound for objective `i`, or `None` if `i` is the primary
109    /// objective (which has no ε bound).
110    pub fn epsilon_for(&self, objective_idx: usize) -> Option<f64> {
111        if objective_idx == self.primary_idx {
112            None
113        } else {
114            self.epsilon.get(objective_idx).copied()
115        }
116    }
117
118    /// Check whether a given objective vector `f` satisfies all ε-constraints.
119    ///
120    /// Returns `true` if `f[i] <= epsilon[i]` for every `i != primary_idx`.
121    ///
122    /// # Arguments
123    /// * `f` — Objective vector with length equal to `self.n_objectives()`.
124    pub fn is_feasible(&self, f: &[f64]) -> bool {
125        for (i, &fi) in f.iter().enumerate() {
126            if i == self.primary_idx {
127                continue;
128            }
129            if let Some(&eps) = self.epsilon.get(i) {
130                if fi > eps {
131                    return false;
132                }
133            }
134        }
135        true
136    }
137
138    /// Compute the constraint violation for a given objective vector.
139    ///
140    /// Returns a vector of violations `max(0, f_i - epsilon_i)` for each
141    /// non-primary objective.  A violation of 0 means the constraint is satisfied.
142    ///
143    /// # Arguments
144    /// * `f` — Objective vector.
145    pub fn violations(&self, f: &[f64]) -> Vec<f64> {
146        f.iter()
147            .enumerate()
148            .filter(|(i, _)| *i != self.primary_idx)
149            .map(|(i, &fi)| {
150                let eps = self.epsilon.get(i).copied().unwrap_or(f64::INFINITY);
151                (fi - eps).max(0.0)
152            })
153            .collect()
154    }
155
156    /// Build a penalized single-objective function from a multi-objective
157    /// closure, suitable for passing to a scalar optimizer.
158    ///
159    /// The penalized objective is:
160    /// ```text
161    /// F(x) = f_primary(x) + penalty * sum_i max(0, f_i(x) - epsilon_i)^2
162    /// ```
163    ///
164    /// # Arguments
165    /// * `objectives`   — Closure mapping `x: &[f64]` → `Vec<f64>` of objectives.
166    /// * `penalty`      — Penalty coefficient for constraint violations.
167    ///
168    /// # Returns
169    /// A closure `fn(&[f64]) -> f64` suitable for scalar minimization.
170    pub fn penalized_objective<F>(&self, objectives: F, penalty: f64) -> impl Fn(&[f64]) -> f64 + '_
171    where
172        F: Fn(&[f64]) -> Vec<f64> + 'static,
173    {
174        let primary = self.primary_idx;
175        let eps = self.epsilon.clone();
176
177        move |x: &[f64]| {
178            let f = objectives(x);
179            let primary_val = f.get(primary).copied().unwrap_or(f64::INFINITY);
180            let violation_penalty: f64 = f
181                .iter()
182                .enumerate()
183                .filter(|(i, _)| *i != primary)
184                .map(|(i, &fi)| {
185                    let bound = eps.get(i).copied().unwrap_or(f64::INFINITY);
186                    (fi - bound).max(0.0).powi(2)
187                })
188                .sum::<f64>()
189                * penalty;
190            primary_val + violation_penalty
191        }
192    }
193}
194
195// ─────────────────────────────────────────────────────────────────────────────
196// EpsilonConstraintSweep — structured Pareto front generation
197// ─────────────────────────────────────────────────────────────────────────────
198
199/// Configuration for systematic Pareto front generation via ε-constraint sweeps.
200#[derive(Debug, Clone)]
201pub struct EpsilonSweepConfig {
202    /// Number of objectives.
203    pub n_objectives: usize,
204    /// Number of ε-levels to test per non-primary objective.
205    pub n_points_per_obj: usize,
206    /// Lower bounds for ε-values (defaults to 0.0 for each objective if empty).
207    pub epsilon_lower: Vec<f64>,
208    /// Upper bounds for ε-values (should be set to estimated nadir point values).
209    pub epsilon_upper: Vec<f64>,
210    /// Penalty coefficient for constraint violations in penalized optimization.
211    pub penalty: f64,
212    /// Maximum iterations for the inner scalar optimizer.
213    pub max_inner_iter: usize,
214    /// Convergence tolerance for the inner optimizer.
215    pub tolerance: f64,
216}
217
218impl EpsilonSweepConfig {
219    /// Create a new sweep configuration.
220    ///
221    /// # Arguments
222    /// * `n_objectives`    — Number of objectives.
223    /// * `n_points_per_obj` — Grid resolution per non-primary objective axis.
224    /// * `epsilon_lower`   — Lower bounds for ε-values.
225    /// * `epsilon_upper`   — Upper bounds for ε-values.
226    pub fn new(
227        n_objectives: usize,
228        n_points_per_obj: usize,
229        epsilon_lower: Vec<f64>,
230        epsilon_upper: Vec<f64>,
231    ) -> OptimizeResult<Self> {
232        if n_objectives < 2 {
233            return Err(OptimizeError::InvalidInput(
234                "n_objectives must be >= 2".to_string(),
235            ));
236        }
237        if n_points_per_obj == 0 {
238            return Err(OptimizeError::InvalidInput(
239                "n_points_per_obj must be >= 1".to_string(),
240            ));
241        }
242        Ok(Self {
243            n_objectives,
244            n_points_per_obj,
245            epsilon_lower,
246            epsilon_upper,
247            penalty: 1e6,
248            max_inner_iter: 200,
249            tolerance: 1e-6,
250        })
251    }
252
253    /// Create a symmetric sweep with `[0.0, 1.0]` ε-bounds for all objectives.
254    pub fn uniform(n_objectives: usize, n_points_per_obj: usize) -> OptimizeResult<Self> {
255        let lower = vec![0.0; n_objectives];
256        let upper = vec![1.0; n_objectives];
257        Self::new(n_objectives, n_points_per_obj, lower, upper)
258    }
259}
260
261/// Result of an epsilon-constraint Pareto front generation sweep.
262#[derive(Debug, Clone)]
263pub struct EpsilonSweepResult {
264    /// Feasible Pareto-front approximate solutions found by the sweep.
265    /// Each element is an objective vector `Vec<f64>`.
266    pub pareto_approximation: Vec<Vec<f64>>,
267    /// Decision vectors corresponding to each Pareto point.
268    pub decision_vectors: Vec<Vec<f64>>,
269    /// Number of subproblems solved.
270    pub n_solved: usize,
271    /// Number of feasible solutions found.
272    pub n_feasible: usize,
273}
274
275/// Generate an approximation of the Pareto front using the epsilon-constraint
276/// method.
277///
278/// Systematically varies ε-bounds for all non-primary objectives and solves
279/// the resulting single-objective constrained subproblems.  Infeasible
280/// subproblems (where no feasible solution exists) are skipped.
281///
282/// # Arguments
283/// * `objectives`  — Multi-objective function: `x: &[f64]` → `Vec<f64>`.
284/// * `bounds`      — Decision variable bounds `[(lo, hi); n_vars]`.
285/// * `primary_obj` — Index of the primary objective to minimize (0-based).
286/// * `config`      — Sweep configuration specifying grid resolution and ε-ranges.
287///
288/// # Returns
289/// An [`EpsilonSweepResult`] containing the Pareto approximation.
290///
291/// # Errors
292/// Returns an error on invalid configuration.
293///
294/// # Notes
295/// This function uses a simple gradient-free interior minimization to solve
296/// each subproblem.  For high accuracy, replace the inner optimizer with a
297/// more sophisticated method appropriate to your problem structure.
298///
299/// # Examples
300/// ```
301/// use scirs2_optimize::constrained::epsilon_constraint::{
302///     generate_pareto_front_epsilon, EpsilonSweepConfig,
303/// };
304///
305/// // 2-variable, 2-objective toy problem
306/// let bounds = vec![(0.0_f64, 1.0_f64); 2];
307/// let config = EpsilonSweepConfig::uniform(2, 5).expect("valid input");
308///
309/// let result = generate_pareto_front_epsilon(
310///     |x| vec![x[0], 1.0 - x[0]],
311///     &bounds,
312///     0,
313///     config,
314/// ).expect("valid input");
315///
316/// assert!(result.n_feasible > 0);
317/// ```
318pub fn generate_pareto_front_epsilon<F>(
319    objectives: F,
320    bounds: &[(f64, f64)],
321    primary_obj: usize,
322    config: EpsilonSweepConfig,
323) -> OptimizeResult<EpsilonSweepResult>
324where
325    F: Fn(&[f64]) -> Vec<f64> + Clone + 'static,
326{
327    let n_obj = config.n_objectives;
328    if primary_obj >= n_obj {
329        return Err(OptimizeError::InvalidInput(format!(
330            "primary_obj={primary_obj} must be < n_objectives={n_obj}"
331        )));
332    }
333    if bounds.is_empty() {
334        return Err(OptimizeError::InvalidInput(
335            "bounds must be non-empty".to_string(),
336        ));
337    }
338
339    // Build the grid of ε-combinations for the non-primary objectives
340    let non_primary: Vec<usize> = (0..n_obj).filter(|&i| i != primary_obj).collect();
341    let n_non_primary = non_primary.len();
342
343    // Build ε-grid values for each non-primary objective
344    let epsilon_grids: Vec<Vec<f64>> = non_primary
345        .iter()
346        .map(|&obj_i| {
347            let lo = config.epsilon_lower.get(obj_i).copied().unwrap_or(0.0);
348            let hi = config.epsilon_upper.get(obj_i).copied().unwrap_or(1.0);
349            let n = config.n_points_per_obj;
350            (0..n)
351                .map(|k| lo + (hi - lo) * k as f64 / (n.max(2) - 1) as f64)
352                .collect()
353        })
354        .collect();
355
356    // Enumerate all combinations using a multi-dimensional counter
357    let total_combos: usize = epsilon_grids
358        .iter()
359        .map(|g| g.len())
360        .product::<usize>()
361        .max(1);
362
363    let mut pareto_approximation: Vec<Vec<f64>> = Vec::new();
364    let mut decision_vectors: Vec<Vec<f64>> = Vec::new();
365    let mut n_solved = 0usize;
366    let mut n_feasible = 0usize;
367
368    let mut counter = vec![0usize; n_non_primary];
369
370    for _ in 0..total_combos {
371        // Build epsilon vector for this combination
372        let mut epsilon: Vec<f64> = vec![f64::INFINITY; n_obj];
373        for (k, &obj_i) in non_primary.iter().enumerate() {
374            epsilon[obj_i] = epsilon_grids[k][counter[k]];
375        }
376
377        let ec = EpsilonConstraint::new(primary_obj, epsilon.clone());
378        let pen_fn = ec.penalized_objective(objectives.clone(), config.penalty);
379
380        // Solve the penalized scalar problem using coordinate descent
381        let x_opt =
382            coordinate_descent_minimize(&pen_fn, bounds, config.max_inner_iter, config.tolerance);
383        n_solved += 1;
384
385        let f_opt = objectives(&x_opt);
386
387        // Accept only feasible solutions
388        if ec.is_feasible(&f_opt) {
389            pareto_approximation.push(f_opt);
390            decision_vectors.push(x_opt);
391            n_feasible += 1;
392        }
393
394        // Increment counter (odometer-style)
395        if !counter.is_empty() {
396            let mut carry = true;
397            for idx in (0..n_non_primary).rev() {
398                if carry {
399                    counter[idx] += 1;
400                    if counter[idx] >= epsilon_grids[idx].len() {
401                        counter[idx] = 0;
402                    } else {
403                        carry = false;
404                    }
405                }
406            }
407        }
408    }
409
410    // Post-process: filter to non-dominated solutions
411    let nd_indices = pareto_filter_nd(&pareto_approximation);
412    let pareto_approximation: Vec<Vec<f64>> = nd_indices
413        .iter()
414        .map(|&i| pareto_approximation[i].clone())
415        .collect();
416    let decision_vectors: Vec<Vec<f64>> = nd_indices
417        .iter()
418        .map(|&i| decision_vectors[i].clone())
419        .collect();
420
421    Ok(EpsilonSweepResult {
422        pareto_approximation,
423        decision_vectors,
424        n_solved,
425        n_feasible,
426    })
427}
428
429// ─────────────────────────────────────────────────────────────────────────────
430// Internal: simple coordinate descent for scalar subproblems
431// ─────────────────────────────────────────────────────────────────────────────
432
433/// Minimize a scalar function over box bounds using coordinate descent.
434///
435/// A lightweight optimizer for the inner subproblems of the ε-constraint
436/// method.  Uses golden-section search along each coordinate.
437fn coordinate_descent_minimize<F>(
438    f: F,
439    bounds: &[(f64, f64)],
440    max_iter: usize,
441    tol: f64,
442) -> Vec<f64>
443where
444    F: Fn(&[f64]) -> f64,
445{
446    let n = bounds.len();
447    // Initialize at midpoint of each variable's range
448    let mut x: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
449
450    let mut prev_val = f(&x);
451
452    for _ in 0..max_iter {
453        for j in 0..n {
454            let (lo, hi) = bounds[j];
455            x[j] = golden_section_1d(&f, &x, j, lo, hi, 30);
456        }
457
458        let curr_val = f(&x);
459        if (prev_val - curr_val).abs() < tol {
460            break;
461        }
462        prev_val = curr_val;
463    }
464
465    x
466}
467
468/// Golden-section search to minimize `f` along dimension `j` of `x`.
469fn golden_section_1d<F>(f: &F, x: &[f64], j: usize, lo: f64, hi: f64, n_iter: usize) -> f64
470where
471    F: Fn(&[f64]) -> f64,
472{
473    let phi = (5.0_f64.sqrt() - 1.0) / 2.0; // ≈ 0.618
474
475    let mut a = lo;
476    let mut b = hi;
477
478    let mut c = b - phi * (b - a);
479    let mut d = a + phi * (b - a);
480
481    let eval = |t: f64| {
482        let mut xc = x.to_vec();
483        xc[j] = t;
484        f(&xc)
485    };
486
487    let mut fc = eval(c);
488    let mut fd = eval(d);
489
490    for _ in 0..n_iter {
491        if fc < fd {
492            b = d;
493            d = c;
494            fd = fc;
495            c = b - phi * (b - a);
496            fc = eval(c);
497        } else {
498            a = c;
499            c = d;
500            fc = fd;
501            d = a + phi * (b - a);
502            fd = eval(d);
503        }
504
505        if (b - a).abs() < 1e-12 {
506            break;
507        }
508    }
509
510    (a + b) / 2.0
511}
512
513/// Filter non-dominated solutions from a set of objective vectors.
514///
515/// Returns indices of non-dominated (Pareto front) solutions.
516fn pareto_filter_nd(objectives: &[Vec<f64>]) -> Vec<usize> {
517    if objectives.is_empty() {
518        return vec![];
519    }
520    let n = objectives.len();
521    let mut dominated = vec![false; n];
522
523    for i in 0..n {
524        if dominated[i] {
525            continue;
526        }
527        for j in 0..n {
528            if i == j || dominated[j] {
529                continue;
530            }
531            if dominates_vec(&objectives[i], &objectives[j]) {
532                dominated[j] = true;
533            }
534        }
535    }
536
537    (0..n).filter(|&i| !dominated[i]).collect()
538}
539
540/// Return true if `a` Pareto-dominates `b`.
541fn dominates_vec(a: &[f64], b: &[f64]) -> bool {
542    let mut any_strict = false;
543    for (ai, bi) in a.iter().zip(b.iter()) {
544        if ai > bi {
545            return false;
546        }
547        if ai < bi {
548            any_strict = true;
549        }
550    }
551    any_strict
552}
553
554// ─────────────────────────────────────────────────────────────────────────────
555// Tests
556// ─────────────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    // ── EpsilonConstraint ─────────────────────────────────────────────────────
563
564    #[test]
565    fn test_ec_feasible_check() {
566        let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.5, 0.8]);
567        // f_0 = 0.3 (primary, ignored), f_1 = 0.4 <= 0.5, f_2 = 0.7 <= 0.8
568        assert!(ec.is_feasible(&[0.3, 0.4, 0.7]));
569        // f_1 = 0.6 > 0.5: infeasible
570        assert!(!ec.is_feasible(&[0.3, 0.6, 0.7]));
571    }
572
573    #[test]
574    fn test_ec_violations() {
575        let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.5]);
576        let v = ec.violations(&[0.3, 0.7]);
577        assert_eq!(v.len(), 1); // 1 non-primary objective
578        assert!((v[0] - 0.2).abs() < 1e-10, "violation = {}", v[0]);
579    }
580
581    #[test]
582    fn test_ec_no_violation_when_feasible() {
583        let ec = EpsilonConstraint::new(1, vec![0.5, f64::INFINITY]);
584        let v = ec.violations(&[0.4, 0.9]);
585        assert_eq!(v.len(), 1);
586        assert_eq!(v[0], 0.0);
587    }
588
589    #[test]
590    fn test_ec_epsilon_for() {
591        let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.3, 0.7]);
592        assert_eq!(ec.epsilon_for(0), None); // primary
593        assert_eq!(ec.epsilon_for(1), Some(0.3));
594        assert_eq!(ec.epsilon_for(2), Some(0.7));
595        assert_eq!(ec.epsilon_for(99), None); // out of range
596    }
597
598    #[test]
599    fn test_ec_new_checked_valid() {
600        let ec = EpsilonConstraint::new_checked(0, vec![f64::INFINITY, 0.5]);
601        assert!(ec.is_ok());
602    }
603
604    #[test]
605    fn test_ec_new_checked_invalid_primary_idx() {
606        let ec = EpsilonConstraint::new_checked(5, vec![f64::INFINITY, 0.5]);
607        assert!(ec.is_err());
608    }
609
610    #[test]
611    fn test_ec_new_checked_empty_epsilon() {
612        let ec = EpsilonConstraint::new_checked(0, vec![]);
613        assert!(ec.is_err());
614    }
615
616    #[test]
617    fn test_ec_penalized_objective_feasible() {
618        // f(x) = [x[0], 1 - x[0]]; constrain f_1 <= 0.6 means x[0] >= 0.4
619        let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.6]);
620        let pen_fn = ec.penalized_objective(|x| vec![x[0], 1.0 - x[0]], 1e4);
621        // x[0] = 0.5: f_1 = 0.5 <= 0.6 (feasible): penalized = 0.5
622        let val = pen_fn(&[0.5]);
623        assert!((val - 0.5).abs() < 1e-10, "expected 0.5, got {val}");
624    }
625
626    #[test]
627    fn test_ec_penalized_objective_infeasible() {
628        // Constrain f_1 <= 0.3 means x[0] >= 0.7
629        let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.3]);
630        let pen_fn = ec.penalized_objective(|x| vec![x[0], 1.0 - x[0]], 1e4);
631        // x[0] = 0.5: f_1 = 0.5 > 0.3 (infeasible): penalty = 1e4 * (0.5-0.3)^2 = 400
632        let val = pen_fn(&[0.5]);
633        assert!(
634            val > 100.0,
635            "infeasible point should be penalized, got {val}"
636        );
637    }
638
639    // ── EpsilonSweepConfig ────────────────────────────────────────────────────
640
641    #[test]
642    fn test_sweep_config_valid() {
643        let cfg = EpsilonSweepConfig::uniform(3, 4);
644        assert!(cfg.is_ok());
645        let cfg = cfg.expect("failed to create cfg");
646        assert_eq!(cfg.n_objectives, 3);
647        assert_eq!(cfg.n_points_per_obj, 4);
648    }
649
650    #[test]
651    fn test_sweep_config_invalid_objectives() {
652        let cfg = EpsilonSweepConfig::uniform(1, 5);
653        assert!(cfg.is_err());
654    }
655
656    #[test]
657    fn test_sweep_config_invalid_points() {
658        let cfg = EpsilonSweepConfig::uniform(2, 0);
659        assert!(cfg.is_err());
660    }
661
662    // ── generate_pareto_front_epsilon ─────────────────────────────────────────
663
664    #[test]
665    fn test_epsilon_pareto_simple_2obj() {
666        // f(x) = [x[0], 1 - x[0]] on x in [0, 1]
667        // True Pareto front is the line f_1 + f_2 = 1
668        let bounds = vec![(0.0_f64, 1.0_f64)];
669        let config = EpsilonSweepConfig::new(2, 5, vec![0.0, 0.0], vec![1.0, 1.0])
670            .expect("failed to create config");
671
672        let result = generate_pareto_front_epsilon(|x| vec![x[0], 1.0 - x[0]], &bounds, 0, config)
673            .expect("unexpected None or Err");
674
675        assert!(result.n_solved > 0, "should have solved some subproblems");
676        // At least some feasible solutions should be found
677        // (some ε combinations may be too tight)
678    }
679
680    #[test]
681    fn test_epsilon_pareto_wrong_primary_obj() {
682        let bounds = vec![(0.0_f64, 1.0_f64)];
683        let config = EpsilonSweepConfig::uniform(2, 3).expect("failed to create config");
684        let result = generate_pareto_front_epsilon(|x| vec![x[0], 1.0 - x[0]], &bounds, 5, config);
685        assert!(result.is_err());
686    }
687
688    #[test]
689    fn test_epsilon_pareto_empty_bounds() {
690        let config = EpsilonSweepConfig::uniform(2, 3).expect("failed to create config");
691        let result = generate_pareto_front_epsilon(|x| vec![x[0]], &[], 0, config);
692        assert!(result.is_err());
693    }
694
695    // ── internal helpers ──────────────────────────────────────────────────────
696
697    #[test]
698    fn test_pareto_filter_nd() {
699        let objectives = vec![
700            vec![1.0, 2.0], // non-dominated
701            vec![2.0, 1.0], // non-dominated
702            vec![3.0, 3.0], // dominated by both
703        ];
704        let nd = pareto_filter_nd(&objectives);
705        assert_eq!(nd.len(), 2);
706        assert!(!nd.contains(&2), "dominated point should not be in result");
707    }
708
709    #[test]
710    fn test_pareto_filter_nd_empty() {
711        let nd = pareto_filter_nd(&[]);
712        assert!(nd.is_empty());
713    }
714
715    #[test]
716    fn test_coordinate_descent_converges_quadratic() {
717        // Minimize (x-0.3)^2 + (y-0.7)^2 on [0,1]^2
718        let bounds = vec![(0.0_f64, 1.0_f64), (0.0_f64, 1.0_f64)];
719        let x_opt = coordinate_descent_minimize(
720            |x| (x[0] - 0.3).powi(2) + (x[1] - 0.7).powi(2),
721            &bounds,
722            100,
723            1e-8,
724        );
725        assert!(
726            (x_opt[0] - 0.3).abs() < 1e-4,
727            "x[0] should be ~0.3, got {}",
728            x_opt[0]
729        );
730        assert!(
731            (x_opt[1] - 0.7).abs() < 1e-4,
732            "x[1] should be ~0.7, got {}",
733            x_opt[1]
734        );
735    }
736}