forge_core/constraint.rs
1//! Nonlinear constraint handling.
2//!
3//! forge's optimizers are box-constrained by design; real calibration and
4//! design problems add **nonlinear** constraints (parameter relationships in
5//! hydrological models, adjacency/budget rules in spatial planning). This
6//! module provides the two standard parameter-free techniques from the CEC
7//! constrained-optimization lineage as **adapters**: a [`ConstrainedProblem`]
8//! is converted into an ordinary [`Problem`], so *every* forge optimizer —
9//! DE, L-SHADE, L-SRTDE, CMA-ES, DDS, SCE-UA, PSO, restarts, ensembles —
10//! handles constraints without modification.
11//!
12//! - [`DebRules`] — Deb's feasibility rules (Deb 2000, CMAME 186:311–338):
13//! feasible beats infeasible; feasible candidates compare by objective;
14//! infeasible candidates compare by total violation. Encoded exactly as
15//! Deb's original fitness transformation: an infeasible point scores
16//! `FEASIBLE_CEILING + total_violation`, above every feasible objective.
17//! - [`EpsilonLShade`](crate::algo::EpsilonLShade) — the ε-constrained
18//! method (Takahama & Sakai lineage, the backbone of CEC winners such as
19//! LSHADE44 and εMAg-ES) lives *inside* the algorithm, not here: violations
20//! up to a shrinking tolerance ε(t) count as feasible, and the comparison
21//! is re-applied with the **current** ε each generation. An adapter cannot
22//! express that faithfully — optimizers cache fitness values, so a point
23//! accepted under an early, generous ε would keep its stale score after ε
24//! contracts and block genuinely feasible solutions.
25//!
26//! **Caveat:** the [`DebRules`] transformation is exact as long as every
27//! feasible objective value is below [`FEASIBLE_CEILING`] (`1e100`).
28
29use crate::problem::{Bound, Problem};
30
31/// Objective values at or above this are reserved for infeasible candidates
32/// (Deb's transformation): `score = FEASIBLE_CEILING · (1 + violation)`.
33/// The encoding is multiplicative because an additive `1e100 + violation`
34/// would be absorbed by the ulp of `1e100` (~1.9e84) and erase the ranking
35/// between different violations.
36pub const FEASIBLE_CEILING: f64 = 1e100;
37
38/// Deb-style score for an infeasible candidate: always ≥ [`FEASIBLE_CEILING`]
39/// and strictly increasing in the violation (down to ~1e-16 relative).
40#[inline]
41fn infeasible_score(violation: f64) -> f64 {
42 FEASIBLE_CEILING * (1.0 + violation)
43}
44
45/// A box-constrained problem with additional nonlinear constraints.
46///
47/// Report constraints through [`violations`](ConstrainedProblem::violations):
48/// one entry per constraint, `0.0` when satisfied and the (positive)
49/// magnitude of the breach otherwise. For an inequality `g(x) ≤ 0` return
50/// `g(x).max(0.0)`; for an equality `h(x) = 0` with tolerance `δ` return
51/// `(h(x).abs() − δ).max(0.0)`.
52pub trait ConstrainedProblem {
53 /// Number of decision variables.
54 fn dim(&self) -> usize;
55
56 /// Per-variable inclusive bounds, length [`ConstrainedProblem::dim`].
57 fn bounds(&self) -> &[Bound];
58
59 /// Objective value to **minimize** (non-finite ⇒ infeasible, as usual).
60 fn objective(&self, x: &[f64]) -> f64;
61
62 /// Constraint violations: `0.0` per satisfied constraint, positive
63 /// magnitudes otherwise. Negative entries are clamped to `0.0`.
64 fn violations(&self, x: &[f64]) -> Vec<f64>;
65
66 /// Total violation `Σ max(vᵢ, 0)` — the quantity Deb's rules compare.
67 fn total_violation(&self, x: &[f64]) -> f64 {
68 self.violations(x).iter().map(|v| v.max(0.0)).sum()
69 }
70}
71
72/// Deb's feasibility rules (Deb 2000) as a [`Problem`] adapter.
73///
74/// Ranking induced: feasible by objective, then infeasible by total
75/// violation — every feasible point beats every infeasible one. Works with
76/// any forge optimizer; exact whenever feasible objectives stay below
77/// [`FEASIBLE_CEILING`].
78///
79/// ```
80/// use forge_core::constraint::{constrained_func, DebRules};
81/// use forge_core::{De, Optimizer, Termination};
82///
83/// // Minimize x + y subject to x·y ≥ 1 on [0, 10]².
84/// let p = constrained_func(
85/// vec![(0.0, 10.0); 2],
86/// |x| x[0] + x[1],
87/// |x| vec![(1.0 - x[0] * x[1]).max(0.0)],
88/// );
89/// let report = De::default().optimize(&DebRules(p), &Termination::budget(6000));
90/// assert!((report.best_value() - 2.0).abs() < 1e-2); // optimum at (1, 1)
91/// ```
92pub struct DebRules<P>(pub P);
93
94impl<P: ConstrainedProblem> Problem for DebRules<P> {
95 fn dim(&self) -> usize {
96 self.0.dim()
97 }
98 fn bounds(&self) -> &[Bound] {
99 self.0.bounds()
100 }
101 fn objective(&self, x: &[f64]) -> f64 {
102 let violation = self.0.total_violation(x);
103 if violation > 0.0 {
104 // Infeasible: rank by violation only, above every feasible value.
105 infeasible_score(violation)
106 } else {
107 self.0.objective(x)
108 }
109 }
110}
111
112/// A [`ConstrainedProblem`] defined inline by closures.
113pub fn constrained_func<F, G>(bounds: Vec<Bound>, f: F, g: G) -> ConstrainedFunc<F, G>
114where
115 F: Fn(&[f64]) -> f64,
116 G: Fn(&[f64]) -> Vec<f64>,
117{
118 ConstrainedFunc { bounds, f, g }
119}
120
121/// Closure-backed constrained problem produced by [`constrained_func`].
122pub struct ConstrainedFunc<F, G> {
123 bounds: Vec<Bound>,
124 f: F,
125 g: G,
126}
127
128impl<F, G> ConstrainedProblem for ConstrainedFunc<F, G>
129where
130 F: Fn(&[f64]) -> f64,
131 G: Fn(&[f64]) -> Vec<f64>,
132{
133 fn dim(&self) -> usize {
134 self.bounds.len()
135 }
136 fn bounds(&self) -> &[Bound] {
137 &self.bounds
138 }
139 fn objective(&self, x: &[f64]) -> f64 {
140 (self.f)(x)
141 }
142 fn violations(&self, x: &[f64]) -> Vec<f64> {
143 (self.g)(x)
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::algo::{LShade, Optimizer};
151 use crate::termination::Termination;
152
153 /// Minimize x² + y² subject to x + y ≥ 1: optimum at (0.5, 0.5), f = 0.5.
154 #[allow(clippy::type_complexity)]
155 fn ring() -> ConstrainedFunc<impl Fn(&[f64]) -> f64, impl Fn(&[f64]) -> Vec<f64>> {
156 constrained_func(
157 vec![(-2.0, 2.0); 2],
158 |x| x[0] * x[0] + x[1] * x[1],
159 |x| vec![(1.0 - x[0] - x[1]).max(0.0)],
160 )
161 }
162
163 #[test]
164 fn deb_rules_find_the_constrained_optimum() {
165 let report = LShade::default().optimize(&DebRules(ring()), &Termination::budget(8000));
166 assert!(
167 (report.best_value() - 0.5).abs() < 1e-3,
168 "got {}",
169 report.best_value()
170 );
171 assert!((report.best()[0] - 0.5).abs() < 0.03);
172 assert!((report.best()[1] - 0.5).abs() < 0.03);
173 }
174
175 #[test]
176 fn infeasible_ranks_by_violation_and_below_feasible() {
177 let p = DebRules(ring());
178 let feasible = p.objective(&[1.0, 1.0]); // violation 0, f = 2
179 let mildly_infeasible = p.objective(&[0.4, 0.4]); // violation 0.2
180 let badly_infeasible = p.objective(&[-1.0, -1.0]); // violation 3
181 assert!(feasible < mildly_infeasible);
182 assert!(mildly_infeasible < badly_infeasible);
183 assert!(mildly_infeasible >= FEASIBLE_CEILING);
184 }
185}