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 optimizers ([`NsgaII`], [`NsgaIII`], [`SmsEmoa`]) return a
41/// Pareto front rather than a single solution. As with [`Problem`], a
42/// non-finite objective value marks a point as infeasible — it is dominated by
43/// every feasible point.
44///
45/// [`NsgaII`]: crate::algo::NsgaII
46/// [`NsgaIII`]: crate::algo::NsgaIII
47/// [`SmsEmoa`]: crate::algo::SmsEmoa
48pub trait MultiProblem {
49    /// Number of decision variables.
50    fn dim(&self) -> usize;
51
52    /// Per-variable inclusive bounds, length [`MultiProblem::dim`].
53    fn bounds(&self) -> &[Bound];
54
55    /// Number of objectives (≥ 2 for a genuine multi-objective problem).
56    fn n_objectives(&self) -> usize;
57
58    /// The objective vector to minimize, length [`MultiProblem::n_objectives`].
59    fn objectives(&self, x: &[f64]) -> Vec<f64>;
60}
61
62/// A [`MultiProblem`] defined inline by a closure, a bounds vector, and an
63/// objective count.
64///
65/// ```
66/// use forge_core::problem::{multi_func, MultiProblem};
67/// // Schaffer N.1: minimize x² and (x−2)² on [-5, 5].
68/// let sch = multi_func(vec![(-5.0, 5.0)], 2, |x| vec![x[0] * x[0], (x[0] - 2.0).powi(2)]);
69/// assert_eq!(sch.n_objectives(), 2);
70/// assert_eq!(sch.objectives(&[1.0]), vec![1.0, 1.0]);
71/// ```
72pub fn multi_func<F>(bounds: Vec<Bound>, n_objectives: usize, f: F) -> MultiFunc<F>
73where
74    F: Fn(&[f64]) -> Vec<f64>,
75{
76    MultiFunc {
77        bounds,
78        n_objectives,
79        f,
80    }
81}
82
83/// Closure-backed multi-objective problem produced by [`multi_func`].
84pub struct MultiFunc<F> {
85    bounds: Vec<Bound>,
86    n_objectives: usize,
87    f: F,
88}
89
90impl<F> MultiProblem for MultiFunc<F>
91where
92    F: Fn(&[f64]) -> Vec<f64>,
93{
94    fn dim(&self) -> usize {
95        self.bounds.len()
96    }
97    fn bounds(&self) -> &[Bound] {
98        &self.bounds
99    }
100    fn n_objectives(&self) -> usize {
101        self.n_objectives
102    }
103    fn objectives(&self, x: &[f64]) -> Vec<f64> {
104        (self.f)(x)
105    }
106}
107
108/// Validates that a problem is well-formed: at least one variable and every
109/// bound strictly ordered (`lower < upper`), rejecting `NaN` bounds.
110pub fn validate(problem: &dyn Problem) -> Result<(), BoundsError> {
111    let b = problem.bounds();
112    if b.is_empty() || problem.dim() == 0 {
113        return Err(BoundsError::Empty);
114    }
115    if b.len() != problem.dim() {
116        return Err(BoundsError::DimMismatch {
117            dim: problem.dim(),
118            bounds: b.len(),
119        });
120    }
121    for (i, &(lo, hi)) in b.iter().enumerate() {
122        // `partial_cmp` rejects NaN too, unlike a plain `lo >= hi`.
123        if lo.partial_cmp(&hi) != Some(std::cmp::Ordering::Less) {
124            return Err(BoundsError::NotOrdered { dim: i, lo, hi });
125        }
126    }
127    Ok(())
128}
129
130/// Validates that a multi-objective problem is well-formed: valid bounds (as
131/// in [`validate`]) plus at least two objectives, and an `objectives()` vector
132/// whose length matches `n_objectives()` at the box midpoint.
133pub fn validate_multi(problem: &dyn MultiProblem) -> Result<(), BoundsError> {
134    struct AsProblem<'a>(&'a dyn MultiProblem);
135    impl Problem for AsProblem<'_> {
136        fn dim(&self) -> usize {
137            self.0.dim()
138        }
139        fn bounds(&self) -> &[Bound] {
140            self.0.bounds()
141        }
142        fn objective(&self, _x: &[f64]) -> f64 {
143            0.0
144        }
145    }
146    validate(&AsProblem(problem))?;
147    let m = problem.n_objectives();
148    if m < 2 {
149        return Err(BoundsError::BadObjectiveCount {
150            declared: m,
151            got: m,
152        });
153    }
154    // Probe once at the box midpoint: a closure returning the wrong number of
155    // objectives would otherwise panic deep inside the non-dominated sort.
156    let mid: Vec<f64> = problem
157        .bounds()
158        .iter()
159        .map(|&(lo, hi)| 0.5 * (lo + hi))
160        .collect();
161    let got = problem.objectives(&mid).len();
162    if got != m {
163        return Err(BoundsError::BadObjectiveCount { declared: m, got });
164    }
165    Ok(())
166}
167
168/// Why a problem's bounds are invalid.
169#[derive(Debug, Clone, PartialEq)]
170#[non_exhaustive]
171pub enum BoundsError {
172    /// No decision variables were given.
173    Empty,
174    /// `bounds().len()` disagrees with `dim()`.
175    DimMismatch {
176        /// What `dim()` reported.
177        dim: usize,
178        /// How many bound pairs `bounds()` returned.
179        bounds: usize,
180    },
181    /// Dimension `dim` has `lower >= upper` (or a NaN bound).
182    NotOrdered {
183        /// Index of the offending variable.
184        dim: usize,
185        /// Its lower bound.
186        lo: f64,
187        /// Its upper bound.
188        hi: f64,
189    },
190    /// `objectives()` length disagrees with `n_objectives()` (or fewer than 2).
191    BadObjectiveCount {
192        /// What `n_objectives()` declared.
193        declared: usize,
194        /// What `objectives()` actually returned.
195        got: usize,
196    },
197}
198
199impl std::fmt::Display for BoundsError {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            BoundsError::Empty => write!(f, "problem must have at least one variable"),
203            BoundsError::DimMismatch { dim, bounds } => {
204                write!(f, "dim() = {dim} but bounds() has {bounds} entries")
205            }
206            BoundsError::NotOrdered { dim, lo, hi } => {
207                write!(
208                    f,
209                    "dimension {dim}: lower bound ({lo}) must be < upper bound ({hi})"
210                )
211            }
212            BoundsError::BadObjectiveCount { declared, got } => {
213                write!(
214                    f,
215                    "n_objectives() declares {declared} but objectives() returned {got} (need >= 2)"
216                )
217            }
218        }
219    }
220}
221
222impl std::error::Error for BoundsError {}
223
224/// A [`Problem`] defined inline by a closure and a bounds vector.
225///
226/// ```
227/// use forge_core::problem::{func, Problem};
228/// let sphere = func(vec![(-5.0, 5.0); 3], |x| x.iter().map(|v| v * v).sum());
229/// assert_eq!(sphere.dim(), 3);
230/// assert_eq!(sphere.objective(&[0.0, 0.0, 0.0]), 0.0);
231/// ```
232pub fn func<F>(bounds: Vec<Bound>, f: F) -> Func<F>
233where
234    F: Fn(&[f64]) -> f64,
235{
236    Func { bounds, f }
237}
238
239/// Closure-backed problem produced by [`func`].
240pub struct Func<F> {
241    bounds: Vec<Bound>,
242    f: F,
243}
244
245impl<F> Problem for Func<F>
246where
247    F: Fn(&[f64]) -> f64,
248{
249    fn dim(&self) -> usize {
250        self.bounds.len()
251    }
252    fn bounds(&self) -> &[Bound] {
253        &self.bounds
254    }
255    fn objective(&self, x: &[f64]) -> f64 {
256        (self.f)(x)
257    }
258}
259
260/// Wraps a problem so the engine **maximizes** its objective instead of
261/// minimizing it, by negating finite values (non-finite stay rejected).
262///
263/// Lets a maximization client (e.g. rainflow maximizing NSE) reuse the
264/// minimizing core unchanged. The reported objective in a [`Solution`] is the
265/// negated, internal value; recover the original sense by negating it back, or
266/// read [`crate::Report::best_value_maximized`].
267///
268/// [`Solution`]: crate::Solution
269pub struct Maximize<P>(pub P);
270
271impl<P: Problem> Problem for Maximize<P> {
272    fn dim(&self) -> usize {
273        self.0.dim()
274    }
275    fn bounds(&self) -> &[Bound] {
276        self.0.bounds()
277    }
278    fn objective(&self, x: &[f64]) -> f64 {
279        let v = self.0.objective(x);
280        if v.is_finite() {
281            -v
282        } else {
283            v
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn func_adapter_works() {
294        let p = func(vec![(-1.0, 1.0), (-1.0, 1.0)], |x| x[0] + x[1]);
295        assert_eq!(p.dim(), 2);
296        assert_eq!(p.objective(&[0.3, 0.4]), 0.7);
297    }
298
299    #[test]
300    fn maximize_negates_finite_only() {
301        let p = Maximize(func(vec![(0.0, 1.0)], |x| x[0]));
302        assert_eq!(p.objective(&[0.6]), -0.6);
303        let bad = Maximize(func(vec![(0.0, 1.0)], |_| f64::NAN));
304        assert!(bad.objective(&[0.5]).is_nan());
305    }
306
307    #[test]
308    fn validate_catches_bad_bounds() {
309        assert_eq!(
310            validate(&func(vec![], |_| 0.0)).unwrap_err(),
311            BoundsError::Empty
312        );
313        assert!(matches!(
314            validate(&func(vec![(1.0, 0.0)], |_| 0.0)).unwrap_err(),
315            BoundsError::NotOrdered { dim: 0, .. }
316        ));
317        assert!(matches!(
318            validate(&func(vec![(0.0, f64::NAN)], |_| 0.0)).unwrap_err(),
319            BoundsError::NotOrdered { .. }
320        ));
321        assert!(validate(&func(vec![(0.0, 1.0), (-2.0, 2.0)], |_| 0.0)).is_ok());
322    }
323}