use crate::error::{OptimizeError, OptimizeResult};
#[derive(Debug, Clone)]
pub struct EpsilonConstraint {
primary_idx: usize,
epsilon: Vec<f64>,
}
impl EpsilonConstraint {
pub fn new_checked(primary_idx: usize, epsilon: Vec<f64>) -> OptimizeResult<Self> {
if epsilon.is_empty() {
return Err(OptimizeError::InvalidInput(
"epsilon vector must be non-empty".to_string(),
));
}
if primary_idx >= epsilon.len() {
return Err(OptimizeError::InvalidInput(format!(
"primary_idx={primary_idx} must be < epsilon.len()={}",
epsilon.len()
)));
}
Ok(Self { primary_idx, epsilon })
}
pub fn new(primary_idx: usize, epsilon: Vec<f64>) -> Self {
Self { primary_idx, epsilon }
}
pub fn primary_objective(&self) -> usize {
self.primary_idx
}
pub fn n_objectives(&self) -> usize {
self.epsilon.len()
}
pub fn epsilon_for(&self, objective_idx: usize) -> Option<f64> {
if objective_idx == self.primary_idx {
None
} else {
self.epsilon.get(objective_idx).copied()
}
}
pub fn is_feasible(&self, f: &[f64]) -> bool {
for (i, &fi) in f.iter().enumerate() {
if i == self.primary_idx {
continue;
}
if let Some(&eps) = self.epsilon.get(i) {
if fi > eps {
return false;
}
}
}
true
}
pub fn violations(&self, f: &[f64]) -> Vec<f64> {
f.iter()
.enumerate()
.filter(|(i, _)| *i != self.primary_idx)
.map(|(i, &fi)| {
let eps = self.epsilon.get(i).copied().unwrap_or(f64::INFINITY);
(fi - eps).max(0.0)
})
.collect()
}
pub fn penalized_objective<F>(
&self,
objectives: F,
penalty: f64,
) -> impl Fn(&[f64]) -> f64 + '_
where
F: Fn(&[f64]) -> Vec<f64> + 'static,
{
let primary = self.primary_idx;
let eps = self.epsilon.clone();
move |x: &[f64]| {
let f = objectives(x);
let primary_val = f.get(primary).copied().unwrap_or(f64::INFINITY);
let violation_penalty: f64 = f
.iter()
.enumerate()
.filter(|(i, _)| *i != primary)
.map(|(i, &fi)| {
let bound = eps.get(i).copied().unwrap_or(f64::INFINITY);
(fi - bound).max(0.0).powi(2)
})
.sum::<f64>()
* penalty;
primary_val + violation_penalty
}
}
}
#[derive(Debug, Clone)]
pub struct EpsilonSweepConfig {
pub n_objectives: usize,
pub n_points_per_obj: usize,
pub epsilon_lower: Vec<f64>,
pub epsilon_upper: Vec<f64>,
pub penalty: f64,
pub max_inner_iter: usize,
pub tolerance: f64,
}
impl EpsilonSweepConfig {
pub fn new(
n_objectives: usize,
n_points_per_obj: usize,
epsilon_lower: Vec<f64>,
epsilon_upper: Vec<f64>,
) -> OptimizeResult<Self> {
if n_objectives < 2 {
return Err(OptimizeError::InvalidInput(
"n_objectives must be >= 2".to_string(),
));
}
if n_points_per_obj == 0 {
return Err(OptimizeError::InvalidInput(
"n_points_per_obj must be >= 1".to_string(),
));
}
Ok(Self {
n_objectives,
n_points_per_obj,
epsilon_lower,
epsilon_upper,
penalty: 1e6,
max_inner_iter: 200,
tolerance: 1e-6,
})
}
pub fn uniform(n_objectives: usize, n_points_per_obj: usize) -> OptimizeResult<Self> {
let lower = vec![0.0; n_objectives];
let upper = vec![1.0; n_objectives];
Self::new(n_objectives, n_points_per_obj, lower, upper)
}
}
#[derive(Debug, Clone)]
pub struct EpsilonSweepResult {
pub pareto_approximation: Vec<Vec<f64>>,
pub decision_vectors: Vec<Vec<f64>>,
pub n_solved: usize,
pub n_feasible: usize,
}
pub fn generate_pareto_front_epsilon<F>(
objectives: F,
bounds: &[(f64, f64)],
primary_obj: usize,
config: EpsilonSweepConfig,
) -> OptimizeResult<EpsilonSweepResult>
where
F: Fn(&[f64]) -> Vec<f64> + Clone + 'static,
{
let n_obj = config.n_objectives;
if primary_obj >= n_obj {
return Err(OptimizeError::InvalidInput(format!(
"primary_obj={primary_obj} must be < n_objectives={n_obj}"
)));
}
if bounds.is_empty() {
return Err(OptimizeError::InvalidInput(
"bounds must be non-empty".to_string(),
));
}
let non_primary: Vec<usize> = (0..n_obj).filter(|&i| i != primary_obj).collect();
let n_non_primary = non_primary.len();
let epsilon_grids: Vec<Vec<f64>> = non_primary
.iter()
.map(|&obj_i| {
let lo = config.epsilon_lower.get(obj_i).copied().unwrap_or(0.0);
let hi = config.epsilon_upper.get(obj_i).copied().unwrap_or(1.0);
let n = config.n_points_per_obj;
(0..n)
.map(|k| lo + (hi - lo) * k as f64 / (n.max(2) - 1) as f64)
.collect()
})
.collect();
let total_combos: usize = epsilon_grids.iter().map(|g| g.len()).product::<usize>().max(1);
let mut pareto_approximation: Vec<Vec<f64>> = Vec::new();
let mut decision_vectors: Vec<Vec<f64>> = Vec::new();
let mut n_solved = 0usize;
let mut n_feasible = 0usize;
let mut counter = vec![0usize; n_non_primary];
for _ in 0..total_combos {
let mut epsilon: Vec<f64> = vec![f64::INFINITY; n_obj];
for (k, &obj_i) in non_primary.iter().enumerate() {
epsilon[obj_i] = epsilon_grids[k][counter[k]];
}
let ec = EpsilonConstraint::new(primary_obj, epsilon.clone());
let pen_fn = ec.penalized_objective(objectives.clone(), config.penalty);
let x_opt = coordinate_descent_minimize(&pen_fn, bounds, config.max_inner_iter, config.tolerance);
n_solved += 1;
let f_opt = objectives(&x_opt);
if ec.is_feasible(&f_opt) {
pareto_approximation.push(f_opt);
decision_vectors.push(x_opt);
n_feasible += 1;
}
if !counter.is_empty() {
let mut carry = true;
for idx in (0..n_non_primary).rev() {
if carry {
counter[idx] += 1;
if counter[idx] >= epsilon_grids[idx].len() {
counter[idx] = 0;
} else {
carry = false;
}
}
}
}
}
let nd_indices = pareto_filter_nd(&pareto_approximation);
let pareto_approximation: Vec<Vec<f64>> = nd_indices
.iter()
.map(|&i| pareto_approximation[i].clone())
.collect();
let decision_vectors: Vec<Vec<f64>> = nd_indices
.iter()
.map(|&i| decision_vectors[i].clone())
.collect();
Ok(EpsilonSweepResult {
pareto_approximation,
decision_vectors,
n_solved,
n_feasible,
})
}
fn coordinate_descent_minimize<F>(
f: F,
bounds: &[(f64, f64)],
max_iter: usize,
tol: f64,
) -> Vec<f64>
where
F: Fn(&[f64]) -> f64,
{
let n = bounds.len();
let mut x: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
let mut prev_val = f(&x);
for _ in 0..max_iter {
for j in 0..n {
let (lo, hi) = bounds[j];
x[j] = golden_section_1d(&f, &x, j, lo, hi, 30);
}
let curr_val = f(&x);
if (prev_val - curr_val).abs() < tol {
break;
}
prev_val = curr_val;
}
x
}
fn golden_section_1d<F>(f: &F, x: &[f64], j: usize, lo: f64, hi: f64, n_iter: usize) -> f64
where
F: Fn(&[f64]) -> f64,
{
let phi = (5.0_f64.sqrt() - 1.0) / 2.0;
let mut a = lo;
let mut b = hi;
let mut c = b - phi * (b - a);
let mut d = a + phi * (b - a);
let eval = |t: f64| {
let mut xc = x.to_vec();
xc[j] = t;
f(&xc)
};
let mut fc = eval(c);
let mut fd = eval(d);
for _ in 0..n_iter {
if fc < fd {
b = d;
d = c;
fd = fc;
c = b - phi * (b - a);
fc = eval(c);
} else {
a = c;
c = d;
fc = fd;
d = a + phi * (b - a);
fd = eval(d);
}
if (b - a).abs() < 1e-12 {
break;
}
}
(a + b) / 2.0
}
fn pareto_filter_nd(objectives: &[Vec<f64>]) -> Vec<usize> {
if objectives.is_empty() {
return vec![];
}
let n = objectives.len();
let mut dominated = vec![false; n];
for i in 0..n {
if dominated[i] {
continue;
}
for j in 0..n {
if i == j || dominated[j] {
continue;
}
if dominates_vec(&objectives[i], &objectives[j]) {
dominated[j] = true;
}
}
}
(0..n).filter(|&i| !dominated[i]).collect()
}
fn dominates_vec(a: &[f64], b: &[f64]) -> bool {
let mut any_strict = false;
for (ai, bi) in a.iter().zip(b.iter()) {
if ai > bi {
return false;
}
if ai < bi {
any_strict = true;
}
}
any_strict
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ec_feasible_check() {
let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.5, 0.8]);
assert!(ec.is_feasible(&[0.3, 0.4, 0.7]));
assert!(!ec.is_feasible(&[0.3, 0.6, 0.7]));
}
#[test]
fn test_ec_violations() {
let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.5]);
let v = ec.violations(&[0.3, 0.7]);
assert_eq!(v.len(), 1); assert!((v[0] - 0.2).abs() < 1e-10, "violation = {}", v[0]);
}
#[test]
fn test_ec_no_violation_when_feasible() {
let ec = EpsilonConstraint::new(1, vec![0.5, f64::INFINITY]);
let v = ec.violations(&[0.4, 0.9]);
assert_eq!(v.len(), 1);
assert_eq!(v[0], 0.0);
}
#[test]
fn test_ec_epsilon_for() {
let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.3, 0.7]);
assert_eq!(ec.epsilon_for(0), None); assert_eq!(ec.epsilon_for(1), Some(0.3));
assert_eq!(ec.epsilon_for(2), Some(0.7));
assert_eq!(ec.epsilon_for(99), None); }
#[test]
fn test_ec_new_checked_valid() {
let ec = EpsilonConstraint::new_checked(0, vec![f64::INFINITY, 0.5]);
assert!(ec.is_ok());
}
#[test]
fn test_ec_new_checked_invalid_primary_idx() {
let ec = EpsilonConstraint::new_checked(5, vec![f64::INFINITY, 0.5]);
assert!(ec.is_err());
}
#[test]
fn test_ec_new_checked_empty_epsilon() {
let ec = EpsilonConstraint::new_checked(0, vec![]);
assert!(ec.is_err());
}
#[test]
fn test_ec_penalized_objective_feasible() {
let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.6]);
let pen_fn = ec.penalized_objective(|x| vec![x[0], 1.0 - x[0]], 1e4);
let val = pen_fn(&[0.5]);
assert!((val - 0.5).abs() < 1e-10, "expected 0.5, got {val}");
}
#[test]
fn test_ec_penalized_objective_infeasible() {
let ec = EpsilonConstraint::new(0, vec![f64::INFINITY, 0.3]);
let pen_fn = ec.penalized_objective(|x| vec![x[0], 1.0 - x[0]], 1e4);
let val = pen_fn(&[0.5]);
assert!(val > 100.0, "infeasible point should be penalized, got {val}");
}
#[test]
fn test_sweep_config_valid() {
let cfg = EpsilonSweepConfig::uniform(3, 4);
assert!(cfg.is_ok());
let cfg = cfg.expect("failed to create cfg");
assert_eq!(cfg.n_objectives, 3);
assert_eq!(cfg.n_points_per_obj, 4);
}
#[test]
fn test_sweep_config_invalid_objectives() {
let cfg = EpsilonSweepConfig::uniform(1, 5);
assert!(cfg.is_err());
}
#[test]
fn test_sweep_config_invalid_points() {
let cfg = EpsilonSweepConfig::uniform(2, 0);
assert!(cfg.is_err());
}
#[test]
fn test_epsilon_pareto_simple_2obj() {
let bounds = vec![(0.0_f64, 1.0_f64)];
let config = EpsilonSweepConfig::new(2, 5, vec![0.0, 0.0], vec![1.0, 1.0]).expect("failed to create config");
let result = generate_pareto_front_epsilon(
|x| vec![x[0], 1.0 - x[0]],
&bounds,
0,
config,
)
.expect("unexpected None or Err");
assert!(result.n_solved > 0, "should have solved some subproblems");
}
#[test]
fn test_epsilon_pareto_wrong_primary_obj() {
let bounds = vec![(0.0_f64, 1.0_f64)];
let config = EpsilonSweepConfig::uniform(2, 3).expect("failed to create config");
let result = generate_pareto_front_epsilon(|x| vec![x[0], 1.0 - x[0]], &bounds, 5, config);
assert!(result.is_err());
}
#[test]
fn test_epsilon_pareto_empty_bounds() {
let config = EpsilonSweepConfig::uniform(2, 3).expect("failed to create config");
let result = generate_pareto_front_epsilon(|x| vec![x[0]], &[], 0, config);
assert!(result.is_err());
}
#[test]
fn test_pareto_filter_nd() {
let objectives = vec![
vec![1.0, 2.0], vec![2.0, 1.0], vec![3.0, 3.0], ];
let nd = pareto_filter_nd(&objectives);
assert_eq!(nd.len(), 2);
assert!(!nd.contains(&2), "dominated point should not be in result");
}
#[test]
fn test_pareto_filter_nd_empty() {
let nd = pareto_filter_nd(&[]);
assert!(nd.is_empty());
}
#[test]
fn test_coordinate_descent_converges_quadratic() {
let bounds = vec![(0.0_f64, 1.0_f64), (0.0_f64, 1.0_f64)];
let x_opt = coordinate_descent_minimize(
|x| (x[0] - 0.3).powi(2) + (x[1] - 0.7).powi(2),
&bounds,
100,
1e-8,
);
assert!((x_opt[0] - 0.3).abs() < 1e-4, "x[0] should be ~0.3, got {}", x_opt[0]);
assert!((x_opt[1] - 0.7).abs() < 1e-4, "x[1] should be ~0.7, got {}", x_opt[1]);
}
}