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}