Skip to main content

forge_core/
problem.rs

1//! The unified optimization problem.
2//!
3//! [`Problem`] is the single interface every forge optimizer consumes: a
4//! box-constrained, real-valued search space plus a scalar objective. By
5//! convention forge **minimizes**; a candidate whose objective is non-finite
6//! (`NaN`/`±∞`) is treated as infeasible and rejected, so models that blow up
7//! on degenerate parameters (a common case in hydrological calibration) need no
8//! special handling.
9//!
10//! Two adapters cover the everyday cases without writing a `struct`:
11//!
12//! - [`func`] wraps a closure with explicit bounds.
13//! - [`Maximize`] flips the sense for objectives that should be *maximized*
14//!   (e.g. NSE/KGE in rainflow), so the minimizing core stays the single
15//!   convention.
16//!
17//! Combinatorial problems (Anvil's bit-flip simulated annealing) are served by
18//! a separate abstraction migrated in a later milestone; v0.1 is the
19//! continuous, population/real-vector substrate that unblocks rainflow.
20
21/// Inclusive search bounds for one decision variable: `lower <= x <= upper`.
22pub type Bound = (f64, f64);
23
24/// A box-constrained, real-valued minimization problem.
25pub trait Problem {
26    /// Number of decision variables.
27    fn dim(&self) -> usize;
28
29    /// Per-variable inclusive bounds, length [`Problem::dim`].
30    fn bounds(&self) -> &[Bound];
31
32    /// Objective value to **minimize**. A non-finite return marks the point as
33    /// infeasible; optimizers will reject it rather than crash.
34    fn objective(&self, x: &[f64]) -> f64;
35}
36
37/// A box-constrained, real-valued **multi-objective** minimization problem.
38///
39/// All objectives are minimized (negate any to be maximized). The
40/// multi-objective optimizer ([`NsgaII`]) returns a Pareto front rather than a
41/// single solution. As with [`Problem`], a non-finite objective value marks a
42/// point as infeasible — it is dominated by every feasible point.
43///
44/// [`NsgaII`]: crate::algo::NsgaII
45pub trait MultiProblem {
46    /// Number of decision variables.
47    fn dim(&self) -> usize;
48
49    /// Per-variable inclusive bounds, length [`MultiProblem::dim`].
50    fn bounds(&self) -> &[Bound];
51
52    /// Number of objectives (≥ 2 for a genuine multi-objective problem).
53    fn n_objectives(&self) -> usize;
54
55    /// The objective vector to minimize, length [`MultiProblem::n_objectives`].
56    fn objectives(&self, x: &[f64]) -> Vec<f64>;
57}
58
59/// A [`MultiProblem`] defined inline by a closure, a bounds vector, and an
60/// objective count.
61///
62/// ```
63/// use forge_core::problem::{multi_func, MultiProblem};
64/// // Schaffer N.1: minimize x² and (x−2)² on [-5, 5].
65/// let sch = multi_func(vec![(-5.0, 5.0)], 2, |x| vec![x[0] * x[0], (x[0] - 2.0).powi(2)]);
66/// assert_eq!(sch.n_objectives(), 2);
67/// assert_eq!(sch.objectives(&[1.0]), vec![1.0, 1.0]);
68/// ```
69pub fn multi_func<F>(bounds: Vec<Bound>, n_objectives: usize, f: F) -> MultiFunc<F>
70where
71    F: Fn(&[f64]) -> Vec<f64>,
72{
73    MultiFunc {
74        bounds,
75        n_objectives,
76        f,
77    }
78}
79
80/// Closure-backed multi-objective problem produced by [`multi_func`].
81pub struct MultiFunc<F> {
82    bounds: Vec<Bound>,
83    n_objectives: usize,
84    f: F,
85}
86
87impl<F> MultiProblem for MultiFunc<F>
88where
89    F: Fn(&[f64]) -> Vec<f64>,
90{
91    fn dim(&self) -> usize {
92        self.bounds.len()
93    }
94    fn bounds(&self) -> &[Bound] {
95        &self.bounds
96    }
97    fn n_objectives(&self) -> usize {
98        self.n_objectives
99    }
100    fn objectives(&self, x: &[f64]) -> Vec<f64> {
101        (self.f)(x)
102    }
103}
104
105/// Validates that a problem is well-formed: at least one variable and every
106/// bound strictly ordered (`lower < upper`), rejecting `NaN` bounds.
107pub fn validate(problem: &dyn Problem) -> Result<(), BoundsError> {
108    let b = problem.bounds();
109    if b.is_empty() || problem.dim() == 0 {
110        return Err(BoundsError::Empty);
111    }
112    if b.len() != problem.dim() {
113        return Err(BoundsError::DimMismatch {
114            dim: problem.dim(),
115            bounds: b.len(),
116        });
117    }
118    for (i, &(lo, hi)) in b.iter().enumerate() {
119        // `partial_cmp` rejects NaN too, unlike a plain `lo >= hi`.
120        if lo.partial_cmp(&hi) != Some(std::cmp::Ordering::Less) {
121            return Err(BoundsError::NotOrdered { dim: i, lo, hi });
122        }
123    }
124    Ok(())
125}
126
127/// Why a problem's bounds are invalid.
128#[derive(Debug, Clone, PartialEq)]
129pub enum BoundsError {
130    /// No decision variables were given.
131    Empty,
132    /// `bounds().len()` disagrees with `dim()`.
133    DimMismatch { dim: usize, bounds: usize },
134    /// Dimension `dim` has `lower >= upper` (or a NaN bound).
135    NotOrdered { dim: usize, lo: f64, hi: f64 },
136}
137
138impl std::fmt::Display for BoundsError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            BoundsError::Empty => write!(f, "problem must have at least one variable"),
142            BoundsError::DimMismatch { dim, bounds } => {
143                write!(f, "dim() = {dim} but bounds() has {bounds} entries")
144            }
145            BoundsError::NotOrdered { dim, lo, hi } => {
146                write!(
147                    f,
148                    "dimension {dim}: lower bound ({lo}) must be < upper bound ({hi})"
149                )
150            }
151        }
152    }
153}
154
155impl std::error::Error for BoundsError {}
156
157/// A [`Problem`] defined inline by a closure and a bounds vector.
158///
159/// ```
160/// use forge_core::problem::{func, Problem};
161/// let sphere = func(vec![(-5.0, 5.0); 3], |x| x.iter().map(|v| v * v).sum());
162/// assert_eq!(sphere.dim(), 3);
163/// assert_eq!(sphere.objective(&[0.0, 0.0, 0.0]), 0.0);
164/// ```
165pub fn func<F>(bounds: Vec<Bound>, f: F) -> Func<F>
166where
167    F: Fn(&[f64]) -> f64,
168{
169    Func { bounds, f }
170}
171
172/// Closure-backed problem produced by [`func`].
173pub struct Func<F> {
174    bounds: Vec<Bound>,
175    f: F,
176}
177
178impl<F> Problem for Func<F>
179where
180    F: Fn(&[f64]) -> f64,
181{
182    fn dim(&self) -> usize {
183        self.bounds.len()
184    }
185    fn bounds(&self) -> &[Bound] {
186        &self.bounds
187    }
188    fn objective(&self, x: &[f64]) -> f64 {
189        (self.f)(x)
190    }
191}
192
193/// Wraps a problem so the engine **maximizes** its objective instead of
194/// minimizing it, by negating finite values (non-finite stay rejected).
195///
196/// Lets a maximization client (e.g. rainflow maximizing NSE) reuse the
197/// minimizing core unchanged. The reported objective in a [`Solution`] is the
198/// negated, internal value; recover the original sense by negating it back, or
199/// read [`crate::Report::best_value_maximized`].
200///
201/// [`Solution`]: crate::Solution
202pub struct Maximize<P>(pub P);
203
204impl<P: Problem> Problem for Maximize<P> {
205    fn dim(&self) -> usize {
206        self.0.dim()
207    }
208    fn bounds(&self) -> &[Bound] {
209        self.0.bounds()
210    }
211    fn objective(&self, x: &[f64]) -> f64 {
212        let v = self.0.objective(x);
213        if v.is_finite() {
214            -v
215        } else {
216            v
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn func_adapter_works() {
227        let p = func(vec![(-1.0, 1.0), (-1.0, 1.0)], |x| x[0] + x[1]);
228        assert_eq!(p.dim(), 2);
229        assert_eq!(p.objective(&[0.3, 0.4]), 0.7);
230    }
231
232    #[test]
233    fn maximize_negates_finite_only() {
234        let p = Maximize(func(vec![(0.0, 1.0)], |x| x[0]));
235        assert_eq!(p.objective(&[0.6]), -0.6);
236        let bad = Maximize(func(vec![(0.0, 1.0)], |_| f64::NAN));
237        assert!(bad.objective(&[0.5]).is_nan());
238    }
239
240    #[test]
241    fn validate_catches_bad_bounds() {
242        assert_eq!(
243            validate(&func(vec![], |_| 0.0)).unwrap_err(),
244            BoundsError::Empty
245        );
246        assert!(matches!(
247            validate(&func(vec![(1.0, 0.0)], |_| 0.0)).unwrap_err(),
248            BoundsError::NotOrdered { dim: 0, .. }
249        ));
250        assert!(matches!(
251            validate(&func(vec![(0.0, f64::NAN)], |_| 0.0)).unwrap_err(),
252            BoundsError::NotOrdered { .. }
253        ));
254        assert!(validate(&func(vec![(0.0, 1.0), (-2.0, 2.0)], |_| 0.0)).is_ok());
255    }
256}