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}