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}