Skip to main content

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}