Skip to main content

pounce_rs/
builder.rs

1//! Ergonomic builder API over the [`TNLP`](crate::TNLP) trait.
2//!
3//! The raw `TNLP` interface is a faithful port of Ipopt's C++ `TNLP` (nine
4//! methods, sparsity bookkeeping, an `Rc<RefCell<dyn TNLP>>` driver) — full
5//! control, but heavy for a simple problem. This module offers the
6//! argmin-style alternative requested in
7//! [#168](https://github.com/jkitchin/pounce/issues/168): implement the small
8//! [`Problem`] trait (only `objective` is required), then configure and solve
9//! with the [`Nlp`] builder. Anything you don't implement is finite-
10//! differenced (gradient / constraint Jacobian) or approximated (the Hessian
11//! defaults to limited-memory L-BFGS), so a basic problem stays small while the
12//! full `TNLP` trait remains available for everything this doesn't expose.
13//!
14//! ```
15//! use pounce_rs::builder::{Problem, Nlp};
16//!
17//! // min (x0-1)^2 + (x1-2)^2  s.t.  x0 + x1 == 3,  0 <= xi <= 5
18//! struct P;
19//! impl Problem for P {
20//!     fn objective(&self, x: &[f64]) -> f64 {
21//!         (x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2)
22//!     }
23//!     fn n_constraints(&self) -> usize { 1 }
24//!     fn constraints(&self, x: &[f64], g: &mut [f64]) { g[0] = x[0] + x[1]; }
25//! }
26//!
27//! let sol = Nlp::new(P)                       // variable count inferred below
28//!     .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
29//!     .constraint_bounds(&[3.0], &[3.0])      // equality: lower == upper
30//!     .x0(&[0.0, 0.0])
31//!     .option_num("tol", 1e-10)
32//!     .solve();
33//!
34//! assert!(sol.success);
35//! assert!((sol.x[0] - 1.0).abs() < 1e-5 && (sol.x[1] - 2.0).abs() < 1e-5);
36//! ```
37
38use std::cell::RefCell;
39use std::rc::Rc;
40
41use crate::{
42    ApplicationReturnStatus, BoundsInfo, IndexStyle, IpoptApplication, IpoptCq, IpoptData, NlpInfo,
43    Solution as TnlpSolution, SolveStatistics, SparsityRequest, StartingPoint, TNLP,
44};
45
46const FD: f64 = 1.4901161193847656e-8; // sqrt(f64::EPSILON)
47const INF: f64 = 2.0e19; // Ipopt's "infinity" bound sentinel
48
49/// A nonlinear program. Implement `objective`; override the rest as needed.
50///
51/// `gradient` / `jacobian` return `false` (their default) to request a
52/// finite-difference approximation. The Hessian is never required — the
53/// builder uses a limited-memory (L-BFGS) approximation by default.
54pub trait Problem {
55    /// Objective `f(x)` to minimize.
56    fn objective(&self, x: &[f64]) -> f64;
57
58    /// Number of constraints `m` (default `0`, i.e. bound-constrained only).
59    fn n_constraints(&self) -> usize {
60        0
61    }
62
63    /// Constraint values `g(x)` into `out` (length `n_constraints`).
64    fn constraints(&self, _x: &[f64], _out: &mut [f64]) {}
65
66    /// Objective gradient `∇f(x)` into `grad`; return `false` for finite
67    /// differences.
68    fn gradient(&self, _x: &[f64], _grad: &mut [f64]) -> bool {
69        false
70    }
71
72    /// Dense constraint Jacobian (row-major, `n_constraints × n`) into `jac`;
73    /// return `false` for finite differences.
74    fn jacobian(&self, _x: &[f64], _jac: &mut [f64]) -> bool {
75        false
76    }
77}
78
79/// The outcome of [`Nlp::solve`].
80///
81/// The vector fields (`x`, `multipliers`, `g`, `z_l`, `z_u`) are filled
82/// by the solver's `finalize_solution` callback. They stay empty
83/// when the solve aborts before finalization, so check
84/// `success`/`status` before indexing.
85#[derive(Debug, Clone)]
86#[non_exhaustive]
87pub struct Solution {
88    /// Solver status; `success` is the convenient boolean.
89    pub status: ApplicationReturnStatus,
90    /// `true` for `SolveSucceeded` / `SolvedToAcceptableLevel`.
91    pub success: bool,
92    /// Optimal variables (length `n`).
93    pub x: Vec<f64>,
94    /// Objective at the solution.
95    pub objective: f64,
96    /// Constraint multipliers `λ` (length `n_constraints`).
97    pub multipliers: Vec<f64>,
98    /// Constraint values `g(x)` at the solution (length `n_constraints`).
99    pub g: Vec<f64>,
100    /// Lower-bound multipliers `z_L` (length `n`).
101    pub z_l: Vec<f64>,
102    /// Upper-bound multipliers `z_U` (length `n`).
103    pub z_u: Vec<f64>,
104    /// Per-solve statistics: wall time (`total_wallclock_time_secs`),
105    /// `iteration_count`, evaluation counts, final scaled and unscaled
106    /// infeasibilities, final barrier `final_mu`, restoration counters.
107    /// `stats.iterations` holds the per-iteration trajectory and is
108    ///  non-empty only when [`Nlp::capture_iterations`] was requested.
109    pub stats: SolveStatistics,
110}
111
112/// Builder: `Nlp::new(problem)` then `.var_bounds(..)` / `.x0(..)` (which fix
113/// the number of variables) and `.solve()`.
114pub struct Nlp<P: Problem> {
115    problem: P,
116    n: Option<usize>, // inferred from var_bounds / x0 (must agree)
117    x_l: Option<Vec<f64>>,
118    x_u: Option<Vec<f64>>,
119    g_l: Vec<f64>,
120    g_u: Vec<f64>,
121    x0: Option<Vec<f64>>,
122    num: Vec<(String, f64)>,
123    int: Vec<(String, i32)>,
124    string: Vec<(String, String)>,
125    capture_iterations: bool,
126}
127
128impl<P: Problem + 'static> Nlp<P> {
129    /// A new builder for `problem`. The number of variables is inferred from
130    /// the first of [`var_bounds`](Self::var_bounds) / [`x0`](Self::x0) you set
131    /// (they must agree); the number of constraints comes from
132    /// `Problem::n_constraints`. Variable bounds default to `±∞`, constraint
133    /// bounds to `0`, and `x0` to the origin.
134    pub fn new(problem: P) -> Self {
135        let m = problem.n_constraints();
136        Nlp {
137            problem,
138            n: None,
139            x_l: None,
140            x_u: None,
141            g_l: vec![0.0; m],
142            g_u: vec![0.0; m],
143            x0: None,
144            num: Vec::new(),
145            int: Vec::new(),
146            string: Vec::new(),
147            capture_iterations: false,
148        }
149    }
150
151    // Record (and cross-check) the variable count implied by a length-`len`
152    // argument.
153    fn set_n(&mut self, len: usize, what: &str) {
154        match self.n {
155            Some(n) if n != len => panic!(
156                "pounce_rs::Nlp: {what} has length {len}, but the problem was \
157                 already sized to {n} variables",
158            ),
159            _ => self.n = Some(len),
160        }
161    }
162
163    /// Variable bounds `x_l ≤ x ≤ x_u` (use `±2e19` for ∞). Fixes the number of
164    /// variables.
165    pub fn var_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
166        assert_eq!(lo.len(), hi.len(), "var_bounds: lo and hi differ in length");
167        self.set_n(lo.len(), "var_bounds");
168        self.x_l = Some(lo.to_vec());
169        self.x_u = Some(hi.to_vec());
170        self
171    }
172
173    /// Constraint bounds `g_l ≤ g(x) ≤ g_u` (`g_l == g_u` is an equality).
174    pub fn constraint_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
175        self.g_l = lo.to_vec();
176        self.g_u = hi.to_vec();
177        self
178    }
179
180    /// Initial guess. Fixes the number of variables.
181    pub fn x0(mut self, x0: &[f64]) -> Self {
182        self.set_n(x0.len(), "x0");
183        self.x0 = Some(x0.to_vec());
184        self
185    }
186
187    /// A numeric solver option (e.g. `("tol", 1e-8)`).
188    pub fn option_num(mut self, tag: &str, value: f64) -> Self {
189        self.num.push((tag.to_string(), value));
190        self
191    }
192
193    /// An integer solver option (e.g. `("max_iter", 500)`).
194    pub fn option_int(mut self, tag: &str, value: i32) -> Self {
195        self.int.push((tag.to_string(), value));
196        self
197    }
198
199    /// A string solver option (e.g. `("mu_strategy", "adaptive")`).
200    pub fn option_str(mut self, tag: &str, value: &str) -> Self {
201        self.string.push((tag.to_string(), value.to_string()));
202        self
203    }
204
205    /// Record the per-iteration trajectory: one
206    /// [`IterRecord`](crate::IterRecord) per Newton iteration into
207    /// [`Solution::stats`]`.iterations` (empty without this call).
208    ///
209    /// Interior-point solves only, since the per-iteration event
210    /// is emitted by the IPM engine, so on the active-set SQP engine
211    /// (`solver_selection=qp-active-set`/`algorithm=active-set-sqp`)
212    /// `stats.iterations` stays empty even though
213    /// `stats.iteration_count` still reports the iterations run.
214    pub fn capture_iterations(mut self) -> Self {
215        self.capture_iterations = true;
216        self
217    }
218
219    /// Build the `TNLP` adapter and run the interior-point solver.
220    ///
221    /// # Panics
222    /// If the number of variables was never fixed (no `var_bounds` or `x0`).
223    pub fn solve(self) -> Solution {
224        let n = self.n.expect(
225            "pounce_rs::Nlp: number of variables unknown — call .var_bounds(..) \
226             or .x0(..) to set it",
227        );
228        let m = self.problem.n_constraints();
229        let adapter = Rc::new(RefCell::new(Adapter {
230            problem: self.problem,
231            n,
232            m,
233            x_l: self.x_l.unwrap_or_else(|| vec![-INF; n]),
234            x_u: self.x_u.unwrap_or_else(|| vec![INF; n]),
235            g_l: self.g_l,
236            g_u: self.g_u,
237            x0: self.x0.unwrap_or_else(|| vec![0.0; n]),
238            sol_x: Vec::new(),
239            sol_obj: 0.0,
240            sol_lambda: Vec::new(),
241            sol_g: Vec::new(),
242            sol_z_l: Vec::new(),
243            sol_z_u: Vec::new(),
244        }));
245
246        let mut app = IpoptApplication::new();
247        app.initialize().expect("IpoptApplication::initialize");
248        // No analytic Hessian is required from `Problem`, so default to L-BFGS.
249        let _ = app.options_mut().set_string_value(
250            "hessian_approximation",
251            "limited-memory",
252            true,
253            true,
254        );
255        // The active-set SQP engine (selected by `solver_selection=qp-active-set`
256        // or `algorithm=active-set-sqp`) reads `sqp_hessian`, whose default
257        // `exact` needs the analytic Hessian this builder never supplies. Default
258        // it to limited-memory BFGS so the SQP route works Hessian-free too; a
259        // user `.option_str("sqp_hessian", ...)` below overrides it.
260        let _ = app
261            .options_mut()
262            .set_string_value("sqp_hessian", "lbfgs", true, true);
263        for (k, v) in &self.string {
264            let _ = app.options_mut().set_string_value(k, v, true, true);
265        }
266        for (k, v) in &self.num {
267            let _ = app.options_mut().set_numeric_value(k, *v, true, true);
268        }
269        for (k, v) in &self.int {
270            let _ = app.options_mut().set_integer_value(k, *v, true, true);
271        }
272
273        let scope = self.capture_iterations.then(|| {
274            app.enable_iter_history();
275            crate::collector_scope()
276        });
277        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&adapter) as _;
278        let status = app.optimize_tnlp(tnlp);
279        drop(scope);
280        let stats = app.statistics();
281        let a = adapter.borrow();
282        Solution {
283            status,
284            success: matches!(
285                status,
286                ApplicationReturnStatus::SolveSucceeded
287                    | ApplicationReturnStatus::SolvedToAcceptableLevel
288            ),
289            x: a.sol_x.clone(),
290            objective: a.sol_obj,
291            multipliers: a.sol_lambda.clone(),
292            g: a.sol_g.clone(),
293            z_l: a.sol_z_l.clone(),
294            z_u: a.sol_z_u.clone(),
295            stats,
296        }
297    }
298}
299
300/// Internal `TNLP` adapter: owns the user [`Problem`] and config, fills in
301/// finite-difference gradient / Jacobian and a dense Jacobian sparsity.
302struct Adapter<P: Problem> {
303    problem: P,
304    n: usize,
305    m: usize,
306    x_l: Vec<f64>,
307    x_u: Vec<f64>,
308    g_l: Vec<f64>,
309    g_u: Vec<f64>,
310    x0: Vec<f64>,
311    sol_x: Vec<f64>,
312    sol_obj: f64,
313    sol_lambda: Vec<f64>,
314    sol_g: Vec<f64>,
315    sol_z_l: Vec<f64>,
316    sol_z_u: Vec<f64>,
317}
318
319impl<P: Problem> TNLP for Adapter<P> {
320    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
321        Some(NlpInfo {
322            n: self.n as i32,
323            m: self.m as i32,
324            nnz_jac_g: (self.m * self.n) as i32, // dense Jacobian
325            nnz_h_lag: 0,                        // L-BFGS: no analytic Hessian
326            index_style: IndexStyle::C,
327        })
328    }
329
330    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
331        b.x_l.copy_from_slice(&self.x_l);
332        b.x_u.copy_from_slice(&self.x_u);
333        b.g_l.copy_from_slice(&self.g_l);
334        b.g_u.copy_from_slice(&self.g_u);
335        true
336    }
337
338    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
339        sp.x.copy_from_slice(&self.x0);
340        true
341    }
342
343    fn eval_f(&mut self, x: &[f64], _new_x: bool) -> Option<f64> {
344        Some(self.problem.objective(x))
345    }
346
347    fn eval_grad_f(&mut self, x: &[f64], _new_x: bool, grad: &mut [f64]) -> bool {
348        if self.problem.gradient(x, grad) {
349            return true;
350        }
351        // forward-difference fallback
352        let f0 = self.problem.objective(x);
353        let mut xp = x.to_vec();
354        for j in 0..self.n {
355            let h = FD * x[j].abs().max(1.0);
356            xp[j] = x[j] + h;
357            grad[j] = (self.problem.objective(&xp) - f0) / h;
358            xp[j] = x[j];
359        }
360        true
361    }
362
363    fn eval_g(&mut self, x: &[f64], _new_x: bool, g: &mut [f64]) -> bool {
364        self.problem.constraints(x, g);
365        true
366    }
367
368    fn eval_jac_g(&mut self, x: Option<&[f64]>, _new_x: bool, mode: SparsityRequest<'_>) -> bool {
369        match mode {
370            SparsityRequest::Structure { irow, jcol } => {
371                let mut k = 0;
372                for i in 0..self.m {
373                    for j in 0..self.n {
374                        irow[k] = i as i32;
375                        jcol[k] = j as i32;
376                        k += 1;
377                    }
378                }
379            }
380            SparsityRequest::Values { values } => {
381                let x = x.expect("eval_jac_g(Values) without x");
382                if self.problem.jacobian(x, values) {
383                    return true;
384                }
385                // forward-difference fallback (dense)
386                let mut g0 = vec![0.0; self.m];
387                self.problem.constraints(x, &mut g0);
388                let mut xp = x.to_vec();
389                let mut gp = vec![0.0; self.m];
390                for j in 0..self.n {
391                    let h = FD * x[j].abs().max(1.0);
392                    xp[j] = x[j] + h;
393                    self.problem.constraints(&xp, &mut gp);
394                    for i in 0..self.m {
395                        values[i * self.n + j] = (gp[i] - g0[i]) / h;
396                    }
397                    xp[j] = x[j];
398                }
399            }
400        }
401        true
402    }
403
404    fn eval_h(
405        &mut self,
406        _x: Option<&[f64]>,
407        _new_x: bool,
408        _obj_factor: f64,
409        _lambda: Option<&[f64]>,
410        _new_lambda: bool,
411        _mode: SparsityRequest<'_>,
412    ) -> bool {
413        false // never called: the builder uses limited-memory (L-BFGS)
414    }
415
416    fn finalize_solution(&mut self, sol: TnlpSolution<'_>, _d: &IpoptData, _q: &IpoptCq) {
417        self.sol_x = sol.x.to_vec();
418        self.sol_obj = sol.obj_value;
419        self.sol_lambda = sol.lambda.to_vec();
420        self.sol_g = sol.g.to_vec();
421        self.sol_z_l = sol.z_l.to_vec();
422        self.sol_z_u = sol.z_u.to_vec();
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    struct Quad; // min (x0-1)^2 + (x1-2)^2  s.t. x0 + x1 == 3
431    impl Problem for Quad {
432        fn objective(&self, x: &[f64]) -> f64 {
433            (x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2)
434        }
435        fn n_constraints(&self) -> usize {
436            1
437        }
438        fn constraints(&self, x: &[f64], g: &mut [f64]) {
439            g[0] = x[0] + x[1];
440        }
441    }
442
443    #[test]
444    fn infers_n_from_bounds_and_solves() {
445        let sol = Nlp::new(Quad)
446            .var_bounds(&[0.0, 0.0], &[5.0, 5.0]) // n inferred = 2
447            .constraint_bounds(&[3.0], &[3.0])
448            .option_num("tol", 1e-10)
449            .solve();
450        assert!(sol.success);
451        assert!((sol.x[0] - 1.0).abs() < 1e-5 && (sol.x[1] - 2.0).abs() < 1e-5);
452    }
453
454    #[test]
455    fn infers_n_from_x0() {
456        let sol = Nlp::new(Quad)
457            .constraint_bounds(&[3.0], &[3.0])
458            .x0(&[0.0, 0.0]) // n inferred = 2
459            .solve();
460        assert!(sol.success);
461    }
462
463    #[test]
464    fn solve_populates_stats_and_duals() {
465        let sol = Nlp::new(Quad)
466            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
467            .constraint_bounds(&[3.0], &[3.0])
468            .solve();
469        assert!(sol.success);
470        assert!(sol.stats.iteration_count > 0);
471        assert!(sol.stats.total_wallclock_time_secs > 0.0);
472        assert!(sol.stats.num_obj_evals > 0);
473        assert!(sol.stats.final_constr_viol < 1e-6);
474        assert_eq!(sol.g.len(), 1);
475        assert!((sol.g[0] - 3.0).abs() < 1e-6, "g at solution: {:?}", sol.g);
476        assert_eq!(sol.z_l.len(), 2);
477        assert_eq!(sol.z_u.len(), 2);
478        assert!(sol.stats.iterations.is_empty());
479    }
480
481    #[test]
482    fn capture_iterations_fills_trajectory() {
483        let sol = Nlp::new(Quad)
484            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
485            .constraint_bounds(&[3.0], &[3.0])
486            .capture_iterations()
487            .solve();
488        assert!(sol.success);
489        let iters = &sol.stats.iterations;
490        assert!(!iters.is_empty(), "no iteration records captured");
491        assert_eq!(iters[0].iter, 0, "trajectory must start at iteration 0");
492        assert!(
493            iters.windows(2).all(|w| w[0].iter < w[1].iter),
494            "iteration counter must be strictly increasing"
495        );
496    }
497
498    #[test]
499    fn capture_iterations_is_empty_on_sqp_engine() {
500        let sol = Nlp::new(Quad)
501            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
502            .constraint_bounds(&[3.0], &[3.0])
503            .option_str("solver_selection", "qp-active-set")
504            .capture_iterations()
505            .solve();
506        assert!(sol.success, "status = {:?}", sol.status);
507        assert!(sol.stats.iteration_count > 0);
508        assert!(sol.stats.iterations.is_empty());
509    }
510
511    #[test]
512    fn qp_active_set_selection_solves() {
513        let sol = Nlp::new(Quad)
514            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
515            .constraint_bounds(&[3.0], &[3.0])
516            .option_str("solver_selection", "qp-active-set")
517            .solve();
518        assert!(sol.success, "status = {:?}", sol.status);
519        assert!((sol.x[0] - 1.0).abs() < 1e-4 && (sol.x[1] - 2.0).abs() < 1e-4);
520    }
521
522    #[test]
523    fn forced_convex_selection_fails_in_builder() {
524        let sol = Nlp::new(Quad)
525            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
526            .constraint_bounds(&[3.0], &[3.0])
527            .option_str("solver_selection", "qp-ipm")
528            .solve();
529        assert!(
530            !sol.success,
531            "forced qp-ipm must not silently succeed via NLP"
532        );
533        assert_eq!(sol.status, ApplicationReturnStatus::InvalidOption);
534    }
535
536    #[test]
537    #[should_panic(expected = "already sized to 2")]
538    fn mismatched_sizes_panic() {
539        let _ = Nlp::new(Quad)
540            .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
541            .x0(&[0.0, 0.0, 0.0]) // length 3 != 2
542            .solve();
543    }
544
545    #[test]
546    #[should_panic(expected = "number of variables unknown")]
547    fn missing_size_panics() {
548        let _ = Nlp::new(Quad).constraint_bounds(&[3.0], &[3.0]).solve();
549    }
550}