Skip to main content

pounce_algorithm/sqp/
ipopt_adapter.rs

1//! Adapter from [`crate::ipopt_nlp::IpoptNlp`] (the rich IPM-
2//! shaped NLP trait pounce-algorithm shares with the IPOPT
3//! lineage) to [`crate::sqp::SqpProblemSpec`] (the minimal
4//! evaluation surface the SQP outer loop binds against).
5//!
6//! Lets `SqpAlgorithm` consume any NLP that the existing IPM
7//! `IpoptAlgorithm` consumes — same `.nl` files via the AMPL
8//! frontend, same CUTEst harness, same Python bindings — without
9//! duplicating the NLP layer.
10//!
11//! Conversions:
12//! - Slice ↔ `DenseVector` for inputs/outputs (per-call allocation;
13//!   the IPM does the same inside `IpoptCalculatedQuantities`).
14//! - `eval_c` and `eval_d` combined into a single constraint
15//!   vector (equalities first, inequalities after). The combined
16//!   bounds set `bl = bu = 0` for equality rows, `bl = d_l[i]`,
17//!   `bu = d_u[i]` for inequality rows.
18//! - `eval_jac_c` and `eval_jac_d` combined into a single
19//!   sparse-triplet Jacobian (inequality-row indices shifted by
20//!   `m_c`).
21//! - `eval_h(x, 1.0, λ[..m_c], λ[m_c..])` for the Lagrangian
22//!   Hessian. The SQP multiplier vector `λ_g` is layout-
23//!   compatible: first `m_c` entries are `y_c`, next `m_d` are
24//!   `y_d`.
25
26use crate::ipopt_nlp::IpoptNlp;
27use crate::sqp::problem::SqpProblemSpec;
28use crate::sqp::qp_assembly::Triplet;
29use pounce_common::Number;
30use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
31use pounce_linalg::expansion_matrix::ExpansionMatrix;
32use pounce_linalg::triplet::{GenTMatrix, SymTMatrix};
33use std::cell::RefCell;
34use std::rc::Rc;
35
36pub struct IpoptNlpAdapter {
37    nlp: Rc<RefCell<dyn IpoptNlp>>,
38    n: usize,
39    m_c: usize,
40    m_d: usize,
41    x_l: Vec<Number>,
42    x_u: Vec<Number>,
43    d_l: Vec<Number>,
44    d_u: Vec<Number>,
45    x_init: Vec<Number>,
46    x_space: Rc<DenseVectorSpace>,
47    c_space: Rc<DenseVectorSpace>,
48    d_space: Rc<DenseVectorSpace>,
49}
50
51impl IpoptNlpAdapter {
52    /// Build the adapter from an IpoptNlp handle. Dimensions are
53    /// queried directly from `Nlp::n()`, `Nlp::m_eq()`,
54    /// `Nlp::m_ineq()`.
55    pub fn new(nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
56        let (n, m_c, m_d) = {
57            let b = nlp.borrow();
58            (b.n() as usize, b.m_eq() as usize, b.m_ineq() as usize)
59        };
60        let x_space = DenseVectorSpace::new(n as i32);
61        let c_space = DenseVectorSpace::new(m_c as i32);
62        let d_space = DenseVectorSpace::new(m_d as i32);
63
64        // Extract bounds and initial point from the NLP. IpoptNlp
65        // exposes bounds in *compressed* form (length = number of
66        // entries with a finite bound); SQP wants full-length
67        // vectors (length n / m_d) with ±∞ for unbounded entries.
68        // The expansion matrices `px_l`, `px_u`, `pd_l`, `pd_u`
69        // own the small→large index map; use them to scatter.
70        let (x_l, x_u, d_l, d_u, x_init) = {
71            let mut n_borrow = nlp.borrow_mut();
72            let x_l_small = vec_from_dyn(n_borrow.x_l());
73            let x_u_small = vec_from_dyn(n_borrow.x_u());
74            let d_l_small = if m_d > 0 {
75                vec_from_dyn(n_borrow.d_l())
76            } else {
77                Vec::new()
78            };
79            let d_u_small = if m_d > 0 {
80                vec_from_dyn(n_borrow.d_u())
81            } else {
82                Vec::new()
83            };
84            let px_l = n_borrow.px_l();
85            let px_u = n_borrow.px_u();
86            let pd_l = n_borrow.pd_l();
87            let pd_u = n_borrow.pd_u();
88            let x_l = scatter_bound(&*px_l, &x_l_small, n, Number::NEG_INFINITY);
89            let x_u = scatter_bound(&*px_u, &x_u_small, n, Number::INFINITY);
90            let d_l = if m_d > 0 {
91                scatter_bound(&*pd_l, &d_l_small, m_d, Number::NEG_INFINITY)
92            } else {
93                Vec::new()
94            };
95            let d_u = if m_d > 0 {
96                scatter_bound(&*pd_u, &d_u_small, m_d, Number::INFINITY)
97            } else {
98                Vec::new()
99            };
100            let mut x = x_space.make_new_dense();
101            let _ = n_borrow.get_starting_x(&mut x);
102            let x_init = x.expanded_values();
103            (x_l, x_u, d_l, d_u, x_init)
104        };
105
106        Self {
107            nlp,
108            n,
109            m_c,
110            m_d,
111            x_l,
112            x_u,
113            d_l,
114            d_u,
115            x_init,
116            x_space,
117            c_space,
118            d_space,
119        }
120    }
121
122    /// The same adapter, but reporting the bounds the user *declared* rather
123    /// than the live ones the interior method widened by
124    /// `bound_relax_factor`.
125    ///
126    /// For the interior iteration the widening is the point — it is what
127    /// keeps a strictly-interior iterate from being pinned against a bound
128    /// it must approach. For anything asking *where the solution sits
129    /// relative to the model*, it inverts the answer: a point exactly on a
130    /// declared bound is a full `1e-8` inside the relaxed one, so an
131    /// activity test against the live bounds calls the binding constraint
132    /// inactive, and a pivot against them stops short of it. That is the
133    /// difference between crossover identifying the active set and
134    /// crossover identifying nothing.
135    ///
136    /// Falls back to the live bounds for any block the NLP does not track
137    /// (the trait accessors default to `None`).
138    pub fn new_with_declared_bounds(nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
139        let mut me = Self::new(Rc::clone(&nlp));
140        let (n, m_d) = (me.n, me.m_d);
141        let b = nlp.borrow();
142        if let Some((x_l_small, x_u_small)) = b.declared_x_bounds() {
143            me.x_l = scatter_bound(&*b.px_l(), &x_l_small, n, Number::NEG_INFINITY);
144            me.x_u = scatter_bound(&*b.px_u(), &x_u_small, n, Number::INFINITY);
145        }
146        if m_d > 0 {
147            if let Some((d_l_small, d_u_small)) = b.declared_d_bounds() {
148                me.d_l = scatter_bound(&*b.pd_l(), &d_l_small, m_d, Number::NEG_INFINITY);
149                me.d_u = scatter_bound(&*b.pd_u(), &d_u_small, m_d, Number::INFINITY);
150            }
151        }
152        drop(b);
153        me
154    }
155
156    fn dv_from_slice(&self, space: &Rc<DenseVectorSpace>, s: &[Number]) -> DenseVector {
157        let mut dv = space.make_new_dense();
158        dv.set_values(s);
159        dv
160    }
161}
162
163impl SqpProblemSpec for IpoptNlpAdapter {
164    fn n(&self) -> usize {
165        self.n
166    }
167    fn m(&self) -> usize {
168        self.m_c + self.m_d
169    }
170
171    fn x_init(&self) -> Vec<Number> {
172        self.x_init.clone()
173    }
174
175    fn variable_bounds(&self) -> (Vec<Number>, Vec<Number>) {
176        (self.x_l.clone(), self.x_u.clone())
177    }
178
179    fn constraint_bounds(&self) -> (Vec<Number>, Vec<Number>) {
180        let mut bl = vec![0.0; self.m_c];
181        bl.extend_from_slice(&self.d_l);
182        let mut bu = vec![0.0; self.m_c];
183        bu.extend_from_slice(&self.d_u);
184        (bl, bu)
185    }
186
187    fn eval_f(&mut self, x: &[Number]) -> Number {
188        let x_dv = self.dv_from_slice(&self.x_space, x);
189        let mut nlp = self.nlp.borrow_mut();
190        nlp.eval_f(&x_dv)
191    }
192
193    fn eval_grad_f(&mut self, x: &[Number]) -> Vec<Number> {
194        let x_dv = self.dv_from_slice(&self.x_space, x);
195        let mut g = self.x_space.make_new_dense();
196        {
197            let mut nlp = self.nlp.borrow_mut();
198            nlp.eval_grad_f(&x_dv, &mut g);
199        }
200        g.expanded_values()
201    }
202
203    fn eval_c(&mut self, x: &[Number]) -> Vec<Number> {
204        let x_dv = self.dv_from_slice(&self.x_space, x);
205        let mut combined = Vec::with_capacity(self.m_c + self.m_d);
206        if self.m_c > 0 {
207            let mut c_out = self.c_space.make_new_dense();
208            {
209                let mut nlp = self.nlp.borrow_mut();
210                nlp.eval_c(&x_dv, &mut c_out);
211            }
212            combined.extend(c_out.expanded_values());
213        }
214        if self.m_d > 0 {
215            let mut d_out = self.d_space.make_new_dense();
216            {
217                let mut nlp = self.nlp.borrow_mut();
218                nlp.eval_d(&x_dv, &mut d_out);
219            }
220            combined.extend(d_out.expanded_values());
221        }
222        combined
223    }
224
225    fn eval_jac_c(&mut self, x: &[Number]) -> Triplet {
226        let x_dv = self.dv_from_slice(&self.x_space, x);
227        let mut irow = Vec::new();
228        let mut jcol = Vec::new();
229        let mut vals = Vec::new();
230
231        if self.m_c > 0 {
232            let jac_c = {
233                let mut nlp = self.nlp.borrow_mut();
234                nlp.eval_jac_c(&x_dv)
235            };
236            let t = gen_t_downcast(&*jac_c);
237            irow.extend_from_slice(t.irows());
238            jcol.extend_from_slice(t.jcols());
239            vals.extend_from_slice(t.values());
240        }
241
242        if self.m_d > 0 {
243            let jac_d = {
244                let mut nlp = self.nlp.borrow_mut();
245                nlp.eval_jac_d(&x_dv)
246            };
247            let t = gen_t_downcast(&*jac_d);
248            let shift = self.m_c as pounce_common::Index;
249            irow.extend(t.irows().iter().map(|&r| r + shift));
250            jcol.extend_from_slice(t.jcols());
251            vals.extend_from_slice(t.values());
252        }
253
254        Triplet {
255            n_rows: self.m_c + self.m_d,
256            n_cols: self.n,
257            irow,
258            jcol,
259            vals,
260        }
261    }
262
263    fn eval_hess_lag(&mut self, x: &[Number], lambda_g: &[Number]) -> Triplet {
264        let x_dv = self.dv_from_slice(&self.x_space, x);
265        let y_c_dv = self.dv_from_slice(&self.c_space, &lambda_g[..self.m_c]);
266        let y_d_dv = self.dv_from_slice(&self.d_space, &lambda_g[self.m_c..]);
267
268        let h = {
269            let mut nlp = self.nlp.borrow_mut();
270            nlp.eval_h(&x_dv, 1.0, &y_c_dv, &y_d_dv)
271        };
272        let t = sym_t_downcast(&*h);
273        Triplet {
274            n_rows: self.n,
275            n_cols: self.n,
276            irow: t.irows().to_vec(),
277            jcol: t.jcols().to_vec(),
278            vals: t.values().to_vec(),
279        }
280    }
281}
282
283fn vec_from_dyn(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
284    let dv = v
285        .as_any()
286        .downcast_ref::<DenseVector>()
287        .expect("IpoptNlp bound accessors must return DenseVector");
288    dv.expanded_values()
289}
290
291/// Scatter a compressed bound vector (length = number of finite bounds)
292/// into the full-length bound vector (length `n_large`), filling
293/// not-in-map entries with `fill`. Uses the `ExpansionMatrix`'s
294/// small→large index map.
295fn scatter_bound(
296    expansion: &dyn pounce_linalg::Matrix,
297    small: &[Number],
298    n_large: usize,
299    fill: Number,
300) -> Vec<Number> {
301    let em = expansion
302        .as_any()
303        .downcast_ref::<ExpansionMatrix>()
304        .expect("px_l / px_u / pd_l / pd_u must be ExpansionMatrix");
305    let exp_pos = em.expanded_pos_indices();
306    debug_assert_eq!(small.len(), exp_pos.len());
307    let mut out = vec![fill; n_large];
308    for (i, &pos) in exp_pos.iter().enumerate() {
309        out[pos as usize] = small[i];
310    }
311    out
312}
313
314fn gen_t_downcast(m: &dyn pounce_linalg::Matrix) -> &GenTMatrix {
315    m.as_any()
316        .downcast_ref::<GenTMatrix>()
317        .expect("IpoptNlp::eval_jac_* must return GenTMatrix")
318}
319
320fn sym_t_downcast(m: &dyn pounce_linalg::matrix::SymMatrix) -> &SymTMatrix {
321    m.as_any()
322        .downcast_ref::<SymTMatrix>()
323        .expect("IpoptNlp::eval_h must return SymTMatrix")
324}
325
326/// Gather the compressed indices of the finite lower / upper variable bounds
327/// — the small→large maps `px_l` and `px_u` own — as plain index vectors.
328///
329/// The IPM carries `z_l` and `z_u` in those *compressed* spaces (one entry
330/// per finite bound), while the SQP / `pounce-qp` side carries one packed
331/// multiplier per variable. Anything translating between the two engines
332/// needs the maps, and there is exactly one correct source for them; reading
333/// them off the NLP here keeps that from being re-derived (wrongly) at each
334/// call site.
335fn bound_maps(nlp: &Rc<RefCell<dyn IpoptNlp>>) -> (Vec<usize>, Vec<usize>) {
336    let b = nlp.borrow();
337    let idx = |m: &dyn pounce_linalg::Matrix| -> Vec<usize> {
338        m.as_any()
339            .downcast_ref::<ExpansionMatrix>()
340            .expect("px_l / px_u must be ExpansionMatrix")
341            .expanded_pos_indices()
342            .iter()
343            .map(|&p| p as usize)
344            .collect()
345    };
346    let lo = idx(&*b.px_l());
347    let up = idx(&*b.px_u());
348    (lo, up)
349}
350
351/// Pack the IPM's compressed bound multipliers into the SQP convention:
352/// one entry per variable, `λ_x = z_l − z_u`.
353///
354/// Both engines write stationarity as `∇f + Jᵀλ_g − λ_x` once `λ_x` is
355/// packed this way (the IPM's own form is `∇f + Jᵀλ − z_l + z_u = 0`), so
356/// this is a repacking and not a sign convention change.
357pub fn pack_bound_multipliers(
358    nlp: &Rc<RefCell<dyn IpoptNlp>>,
359    z_l: &[Number],
360    z_u: &[Number],
361) -> Vec<Number> {
362    let n = nlp.borrow().n() as usize;
363    let (lo_map, up_map) = bound_maps(nlp);
364    let mut out = vec![0.0; n];
365    for (i, &pos) in lo_map.iter().enumerate() {
366        if let Some(&v) = z_l.get(i) {
367            out[pos] += v;
368        }
369    }
370    for (i, &pos) in up_map.iter().enumerate() {
371        if let Some(&v) = z_u.get(i) {
372            out[pos] -= v;
373        }
374    }
375    out
376}
377
378/// Inverse of [`pack_bound_multipliers`]: split a packed per-variable `λ_x`
379/// back into the IPM's compressed `(z_l, z_u)`.
380///
381/// The split is by sign — positive mass to the lower bound, negative to the
382/// upper — which is the only choice consistent with the sign restrictions
383/// `z_l ≥ 0`, `z_u ≥ 0` and with complementarity: away from a degenerate
384/// fixed variable at most one of the two can be nonzero, so the packing loses
385/// nothing to recover. A variable *fixed* by equal bounds is the one case
386/// where `λ_x` genuinely does not determine the pair; sign choice is the
387/// established convention there and the two are interchangeable in every
388/// downstream identity, since only their difference is ever used.
389///
390/// Mass that lands on a variable with no bound on the corresponding side has
391/// nowhere to go and is dropped — that can only happen if a caller hands in a
392/// multiplier violating the sign restrictions, and silently keeping it would
393/// corrupt the stationarity residual the user is shown.
394pub fn split_bound_multipliers(
395    nlp: &Rc<RefCell<dyn IpoptNlp>>,
396    lambda_x: &[Number],
397) -> (Vec<Number>, Vec<Number>) {
398    let (lo_map, up_map) = bound_maps(nlp);
399    let z_l = lo_map
400        .iter()
401        .map(|&pos| lambda_x.get(pos).copied().unwrap_or(0.0).max(0.0))
402        .collect();
403    let z_u = up_map
404        .iter()
405        .map(|&pos| (-lambda_x.get(pos).copied().unwrap_or(0.0)).max(0.0))
406        .collect();
407    (z_l, z_u)
408}
409
410/// Split the inequality multipliers `y_d` into the IPM's compressed slack
411/// bound multipliers `(v_l, v_u)`.
412///
413/// Stationarity of the barrier problem with respect to the slacks is
414/// `−y_d − v_l + v_u = 0`, i.e. `v_l − v_u = −y_d`; with `v_l, v_u ≥ 0` and
415/// complementarity that determines the pair up to the same degenerate case
416/// [`split_bound_multipliers`] documents. Needed when writing a point
417/// computed on the active-set side back onto an IPM iterate, whose slack
418/// duals would otherwise still describe the interior point.
419pub fn split_slack_multipliers(
420    nlp: &Rc<RefCell<dyn IpoptNlp>>,
421    y_d: &[Number],
422) -> (Vec<Number>, Vec<Number>) {
423    let b = nlp.borrow();
424    let idx = |m: &dyn pounce_linalg::Matrix| -> Vec<usize> {
425        m.as_any()
426            .downcast_ref::<ExpansionMatrix>()
427            .expect("pd_l / pd_u must be ExpansionMatrix")
428            .expanded_pos_indices()
429            .iter()
430            .map(|&p| p as usize)
431            .collect()
432    };
433    let lo_map = idx(&*b.pd_l());
434    let up_map = idx(&*b.pd_u());
435    let v_l = lo_map
436        .iter()
437        .map(|&pos| (-y_d.get(pos).copied().unwrap_or(0.0)).max(0.0))
438        .collect();
439    let v_u = up_map
440        .iter()
441        .map(|&pos| y_d.get(pos).copied().unwrap_or(0.0).max(0.0))
442        .collect();
443    (v_l, v_u)
444}