Skip to main content

forge_core/
testfn.rs

1//! Standard optimization test functions, as [`Problem`]s.
2//!
3//! These are the classic benchmarks used to validate global optimizers: the
4//! convex unimodal **sphere**, the narrow-valley **Rosenbrock**, and the highly
5//! multimodal **Rastrigin**. All have a known global minimum of `0`, which the
6//! integration tests check each algorithm can reach.
7
8use crate::problem::{Bound, MultiProblem, Problem};
9
10/// Sphere: `f(x) = Σ xᵢ²`. Convex, unimodal, minimum `0` at the origin.
11pub struct Sphere {
12    bounds: Vec<Bound>,
13}
14
15impl Sphere {
16    /// `dim`-dimensional sphere on `[-5.12, 5.12]^dim`.
17    pub fn new(dim: usize) -> Self {
18        Sphere {
19            bounds: vec![(-5.12, 5.12); dim],
20        }
21    }
22}
23
24impl Problem for Sphere {
25    fn dim(&self) -> usize {
26        self.bounds.len()
27    }
28    fn bounds(&self) -> &[Bound] {
29        &self.bounds
30    }
31    fn objective(&self, x: &[f64]) -> f64 {
32        x.iter().map(|v| v * v).sum()
33    }
34}
35
36/// Rosenbrock: `f(x) = Σ [100(x_{i+1} − xᵢ²)² + (1 − xᵢ)²]`. A long, curved,
37/// nearly-flat valley; minimum `0` at the all-ones vector.
38pub struct Rosenbrock {
39    bounds: Vec<Bound>,
40}
41
42impl Rosenbrock {
43    /// `dim`-dimensional Rosenbrock (`dim >= 2`) on `[-5, 10]^dim`.
44    pub fn new(dim: usize) -> Self {
45        assert!(dim >= 2, "Rosenbrock needs at least 2 dimensions");
46        Rosenbrock {
47            bounds: vec![(-5.0, 10.0); dim],
48        }
49    }
50}
51
52impl Problem for Rosenbrock {
53    fn dim(&self) -> usize {
54        self.bounds.len()
55    }
56    fn bounds(&self) -> &[Bound] {
57        &self.bounds
58    }
59    fn objective(&self, x: &[f64]) -> f64 {
60        x.windows(2)
61            .map(|w| 100.0 * (w[1] - w[0] * w[0]).powi(2) + (1.0 - w[0]).powi(2))
62            .sum()
63    }
64}
65
66/// Rastrigin: `f(x) = 10n + Σ [xᵢ² − 10 cos(2π xᵢ)]`. Highly multimodal with a
67/// regular lattice of local minima; global minimum `0` at the origin.
68pub struct Rastrigin {
69    bounds: Vec<Bound>,
70}
71
72impl Rastrigin {
73    /// `dim`-dimensional Rastrigin on `[-5.12, 5.12]^dim`.
74    pub fn new(dim: usize) -> Self {
75        Rastrigin {
76            bounds: vec![(-5.12, 5.12); dim],
77        }
78    }
79}
80
81impl Problem for Rastrigin {
82    fn dim(&self) -> usize {
83        self.bounds.len()
84    }
85    fn bounds(&self) -> &[Bound] {
86        &self.bounds
87    }
88    fn objective(&self, x: &[f64]) -> f64 {
89        let n = x.len() as f64;
90        10.0 * n
91            + x.iter()
92                .map(|v| v * v - 10.0 * (2.0 * std::f64::consts::PI * v).cos())
93                .sum::<f64>()
94    }
95}
96
97/// ZDT1 (Zitzler, Deb & Thiele 2000): a two-objective benchmark with a known,
98/// convex Pareto front. On `[0, 1]^n`,
99///
100/// ```text
101/// f1 = x1
102/// g  = 1 + 9 · (Σ_{i≥2} xi) / (n − 1)
103/// f2 = g · (1 − √(f1 / g))
104/// ```
105///
106/// The Pareto-optimal set has `x2 … xn = 0` (so `g = 1`), giving the analytical
107/// front `f2 = 1 − √f1` for `f1 ∈ [0, 1]` — the ground truth the NSGA-II test
108/// measures distance to.
109pub struct Zdt1 {
110    bounds: Vec<Bound>,
111}
112
113impl Zdt1 {
114    /// `dim`-dimensional ZDT1 (`dim >= 2`) on `[0, 1]^dim`.
115    pub fn new(dim: usize) -> Self {
116        assert!(dim >= 2, "ZDT1 needs at least 2 dimensions");
117        Zdt1 {
118            bounds: vec![(0.0, 1.0); dim],
119        }
120    }
121
122    /// The analytical Pareto front value `f2 = 1 − √f1` for a given `f1`.
123    pub fn front_f2(f1: f64) -> f64 {
124        1.0 - f1.sqrt()
125    }
126}
127
128impl MultiProblem for Zdt1 {
129    fn dim(&self) -> usize {
130        self.bounds.len()
131    }
132    fn bounds(&self) -> &[Bound] {
133        &self.bounds
134    }
135    fn n_objectives(&self) -> usize {
136        2
137    }
138    fn objectives(&self, x: &[f64]) -> Vec<f64> {
139        let n = x.len();
140        let f1 = x[0];
141        let g = 1.0 + 9.0 * x[1..].iter().sum::<f64>() / (n - 1) as f64;
142        let f2 = g * (1.0 - (f1 / g).sqrt());
143        vec![f1, f2]
144    }
145}
146
147/// DTLZ2 (Deb, Thiele, Laumanns & Zitzler 2002): a scalable many-objective
148/// benchmark whose Pareto front is the unit hypersphere in the positive orthant
149/// (`Σ fᵢ² = 1`). On `[0, 1]^n` with `M` objectives and `k = n − M + 1` distance
150/// variables,
151///
152/// ```text
153/// g    = Σ_{i ≥ M} (xᵢ − 0.5)²
154/// f_1  = (1+g) · cos(x₁ π/2) ··· cos(x_{M−1} π/2)
155/// f_j  = (1+g) · cos(x₁ π/2) ··· sin(x_{M−j+1} π/2)
156/// f_M  = (1+g) · sin(x₁ π/2)
157/// ```
158///
159/// The front is reached when `g = 0` (all distance variables at 0.5); there the
160/// objective vector lies exactly on the unit sphere — the ground truth the
161/// NSGA-III test measures distance to.
162pub struct Dtlz2 {
163    bounds: Vec<Bound>,
164    m: usize,
165}
166
167impl Dtlz2 {
168    /// `m`-objective DTLZ2 in `dim` variables (`dim > m`) on `[0, 1]^dim`. The
169    /// recommended `dim` is `m + 9` (so `k = 10` distance variables).
170    pub fn new(m: usize, dim: usize) -> Self {
171        assert!(m >= 2 && dim > m, "DTLZ2 needs m >= 2 and dim > m");
172        Dtlz2 {
173            bounds: vec![(0.0, 1.0); dim],
174            m,
175        }
176    }
177}
178
179impl MultiProblem for Dtlz2 {
180    fn dim(&self) -> usize {
181        self.bounds.len()
182    }
183    fn bounds(&self) -> &[Bound] {
184        &self.bounds
185    }
186    fn n_objectives(&self) -> usize {
187        self.m
188    }
189    fn objectives(&self, x: &[f64]) -> Vec<f64> {
190        let m = self.m;
191        let g: f64 = x[m - 1..].iter().map(|&xi| (xi - 0.5).powi(2)).sum();
192        let half_pi = std::f64::consts::FRAC_PI_2;
193        let mut f = vec![1.0 + g; m];
194        for (i, fi) in f.iter_mut().enumerate() {
195            // Product of cosines for the first (M-1-i) angles...
196            for &xj in x.iter().take(m - 1 - i) {
197                *fi *= (xj * half_pi).cos();
198            }
199            // ...and one sine, except for the first objective.
200            if i > 0 {
201                *fi *= (x[m - 1 - i] * half_pi).sin();
202            }
203        }
204        f
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn known_minima_are_zero() {
214        assert_eq!(Sphere::new(3).objective(&[0.0, 0.0, 0.0]), 0.0);
215        assert!(Rosenbrock::new(3).objective(&[1.0, 1.0, 1.0]).abs() < 1e-12);
216        assert!(Rastrigin::new(4).objective(&[0.0; 4]).abs() < 1e-12);
217    }
218
219    #[test]
220    fn dtlz2_front_lies_on_the_unit_sphere() {
221        // Distance variables at 0.5 ⇒ g = 0 ⇒ Σ fᵢ² = 1 for any angle variables.
222        let p = Dtlz2::new(3, 12);
223        for angles in [[0.0, 0.0], [0.5, 0.5], [1.0, 0.3]] {
224            let mut x = vec![0.5; 12];
225            x[0] = angles[0];
226            x[1] = angles[1];
227            let f = p.objectives(&x);
228            let sumsq: f64 = f.iter().map(|v| v * v).sum();
229            assert!((sumsq - 1.0).abs() < 1e-9, "Σf² = {sumsq}");
230        }
231    }
232
233    #[test]
234    fn zdt1_pareto_set_lies_on_the_analytical_front() {
235        // x2..xn = 0 ⇒ g = 1 ⇒ f2 = 1 − √f1 for any f1 = x1.
236        let p = Zdt1::new(5);
237        for &f1 in &[0.0, 0.25, 0.5, 1.0] {
238            let mut x = vec![0.0; 5];
239            x[0] = f1;
240            let o = p.objectives(&x);
241            assert!((o[0] - f1).abs() < 1e-12);
242            assert!((o[1] - Zdt1::front_f2(f1)).abs() < 1e-12);
243        }
244    }
245}