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    /// Constraint violation of the model **as the user declared it**, in
157    /// user units, at `x`: the general rows (unscaled, against the declared
158    /// bounds) and the variable box (`None` when the declared box was never
159    /// snapshotted). The SQP's own residuals are in the scaled space, where
160    /// gradient-based scaling can shrink a row's violation by orders of
161    /// magnitude (gh#1054: a violation of `1` read `3.3e-7`), so these are
162    /// what an acceptance decision about the user's problem has to read.
163    pub fn declared_violation(&self, x: &[Number]) -> (Number, Option<Number>) {
164        let x_dv = self.dv_from_slice(&self.x_space, x);
165        let mut c = self.c_space.make_new_dense();
166        let mut d = self.d_space.make_new_dense();
167        {
168            let mut nlp = self.nlp.borrow_mut();
169            if self.m_c > 0 {
170                nlp.eval_c(&x_dv, &mut c);
171            }
172            if self.m_d > 0 {
173                nlp.eval_d(&x_dv, &mut d);
174            }
175        }
176        let nlp = self.nlp.borrow();
177        let rows = crate::ipopt_cq::unscaled_nlp_constraint_violation_max(&*nlp, &c, &d);
178        (rows, nlp.declared_box_violation(&x_dv))
179    }
180
181    /// `|df|`, the objective scaling factor's magnitude (`1` when unset):
182    /// the divisor that takes a stationarity or complementarity residual
183    /// from the scaled space to user units, as
184    /// `IpoptCalculatedQuantities::curr_unscaled_dual_infeasibility_max` does.
185    pub fn obj_scale(&self) -> Number {
186        let df = self.nlp.borrow().obj_scaling_factor().abs();
187        if df == 0.0 { 1.0 } else { df }
188    }
189
190    fn dv_from_slice(&self, space: &Rc<DenseVectorSpace>, s: &[Number]) -> DenseVector {
191        let mut dv = space.make_new_dense();
192        dv.set_values(s);
193        dv
194    }
195}
196
197impl SqpProblemSpec for IpoptNlpAdapter {
198    fn n(&self) -> usize {
199        self.n
200    }
201    fn m(&self) -> usize {
202        self.m_c + self.m_d
203    }
204
205    fn x_init(&self) -> Vec<Number> {
206        self.x_init.clone()
207    }
208
209    fn variable_bounds(&self) -> (Vec<Number>, Vec<Number>) {
210        (self.x_l.clone(), self.x_u.clone())
211    }
212
213    fn constraint_bounds(&self) -> (Vec<Number>, Vec<Number>) {
214        let mut bl = vec![0.0; self.m_c];
215        bl.extend_from_slice(&self.d_l);
216        let mut bu = vec![0.0; self.m_c];
217        bu.extend_from_slice(&self.d_u);
218        (bl, bu)
219    }
220
221    fn eval_f(&mut self, x: &[Number]) -> Number {
222        let x_dv = self.dv_from_slice(&self.x_space, x);
223        let mut nlp = self.nlp.borrow_mut();
224        nlp.eval_f(&x_dv)
225    }
226
227    fn eval_grad_f(&mut self, x: &[Number]) -> Vec<Number> {
228        let x_dv = self.dv_from_slice(&self.x_space, x);
229        let mut g = self.x_space.make_new_dense();
230        {
231            let mut nlp = self.nlp.borrow_mut();
232            nlp.eval_grad_f(&x_dv, &mut g);
233        }
234        g.expanded_values()
235    }
236
237    fn eval_c(&mut self, x: &[Number]) -> Vec<Number> {
238        let x_dv = self.dv_from_slice(&self.x_space, x);
239        let mut combined = Vec::with_capacity(self.m_c + self.m_d);
240        if self.m_c > 0 {
241            let mut c_out = self.c_space.make_new_dense();
242            {
243                let mut nlp = self.nlp.borrow_mut();
244                nlp.eval_c(&x_dv, &mut c_out);
245            }
246            combined.extend(c_out.expanded_values());
247        }
248        if self.m_d > 0 {
249            let mut d_out = self.d_space.make_new_dense();
250            {
251                let mut nlp = self.nlp.borrow_mut();
252                nlp.eval_d(&x_dv, &mut d_out);
253            }
254            combined.extend(d_out.expanded_values());
255        }
256        combined
257    }
258
259    fn eval_jac_c(&mut self, x: &[Number]) -> Triplet {
260        let x_dv = self.dv_from_slice(&self.x_space, x);
261        let mut irow = Vec::new();
262        let mut jcol = Vec::new();
263        let mut vals = Vec::new();
264
265        if self.m_c > 0 {
266            let jac_c = {
267                let mut nlp = self.nlp.borrow_mut();
268                nlp.eval_jac_c(&x_dv)
269            };
270            let t = gen_t_downcast(&*jac_c);
271            irow.extend_from_slice(t.irows());
272            jcol.extend_from_slice(t.jcols());
273            vals.extend_from_slice(t.values());
274        }
275
276        if self.m_d > 0 {
277            let jac_d = {
278                let mut nlp = self.nlp.borrow_mut();
279                nlp.eval_jac_d(&x_dv)
280            };
281            let t = gen_t_downcast(&*jac_d);
282            let shift = self.m_c as pounce_common::Index;
283            irow.extend(t.irows().iter().map(|&r| r + shift));
284            jcol.extend_from_slice(t.jcols());
285            vals.extend_from_slice(t.values());
286        }
287
288        Triplet {
289            n_rows: self.m_c + self.m_d,
290            n_cols: self.n,
291            irow,
292            jcol,
293            vals,
294        }
295    }
296
297    fn eval_hess_lag(&mut self, x: &[Number], lambda_g: &[Number]) -> Triplet {
298        let x_dv = self.dv_from_slice(&self.x_space, x);
299        let y_c_dv = self.dv_from_slice(&self.c_space, &lambda_g[..self.m_c]);
300        let y_d_dv = self.dv_from_slice(&self.d_space, &lambda_g[self.m_c..]);
301
302        let h = {
303            let mut nlp = self.nlp.borrow_mut();
304            nlp.eval_h(&x_dv, 1.0, &y_c_dv, &y_d_dv)
305        };
306        let t = sym_t_downcast(&*h);
307        Triplet {
308            n_rows: self.n,
309            n_cols: self.n,
310            irow: t.irows().to_vec(),
311            jcol: t.jcols().to_vec(),
312            vals: t.values().to_vec(),
313        }
314    }
315}
316
317fn vec_from_dyn(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
318    let dv = v
319        .as_any()
320        .downcast_ref::<DenseVector>()
321        .expect("IpoptNlp bound accessors must return DenseVector");
322    dv.expanded_values()
323}
324
325/// Scatter a compressed bound vector (length = number of finite bounds)
326/// into the full-length bound vector (length `n_large`), filling
327/// not-in-map entries with `fill`. Uses the `ExpansionMatrix`'s
328/// small→large index map.
329fn scatter_bound(
330    expansion: &dyn pounce_linalg::Matrix,
331    small: &[Number],
332    n_large: usize,
333    fill: Number,
334) -> Vec<Number> {
335    let em = expansion
336        .as_any()
337        .downcast_ref::<ExpansionMatrix>()
338        .expect("px_l / px_u / pd_l / pd_u must be ExpansionMatrix");
339    let exp_pos = em.expanded_pos_indices();
340    debug_assert_eq!(small.len(), exp_pos.len());
341    let mut out = vec![fill; n_large];
342    for (i, &pos) in exp_pos.iter().enumerate() {
343        out[pos as usize] = small[i];
344    }
345    out
346}
347
348fn gen_t_downcast(m: &dyn pounce_linalg::Matrix) -> &GenTMatrix {
349    m.as_any()
350        .downcast_ref::<GenTMatrix>()
351        .expect("IpoptNlp::eval_jac_* must return GenTMatrix")
352}
353
354fn sym_t_downcast(m: &dyn pounce_linalg::matrix::SymMatrix) -> &SymTMatrix {
355    m.as_any()
356        .downcast_ref::<SymTMatrix>()
357        .expect("IpoptNlp::eval_h must return SymTMatrix")
358}
359
360/// Gather the compressed indices of the finite lower / upper variable bounds
361/// — the small→large maps `px_l` and `px_u` own — as plain index vectors.
362///
363/// The IPM carries `z_l` and `z_u` in those *compressed* spaces (one entry
364/// per finite bound), while the SQP / `pounce-qp` side carries one packed
365/// multiplier per variable. Anything translating between the two engines
366/// needs the maps, and there is exactly one correct source for them; reading
367/// them off the NLP here keeps that from being re-derived (wrongly) at each
368/// call site.
369fn bound_maps(nlp: &Rc<RefCell<dyn IpoptNlp>>) -> (Vec<usize>, Vec<usize>) {
370    let b = nlp.borrow();
371    let idx = |m: &dyn pounce_linalg::Matrix| -> Vec<usize> {
372        m.as_any()
373            .downcast_ref::<ExpansionMatrix>()
374            .expect("px_l / px_u must be ExpansionMatrix")
375            .expanded_pos_indices()
376            .iter()
377            .map(|&p| p as usize)
378            .collect()
379    };
380    let lo = idx(&*b.px_l());
381    let up = idx(&*b.px_u());
382    (lo, up)
383}
384
385/// Pack the IPM's compressed bound multipliers into the SQP convention:
386/// one entry per variable, `λ_x = z_l − z_u`.
387///
388/// Both engines write stationarity as `∇f + Jᵀλ_g − λ_x` once `λ_x` is
389/// packed this way (the IPM's own form is `∇f + Jᵀλ − z_l + z_u = 0`), so
390/// this is a repacking and not a sign convention change.
391pub fn pack_bound_multipliers(
392    nlp: &Rc<RefCell<dyn IpoptNlp>>,
393    z_l: &[Number],
394    z_u: &[Number],
395) -> Vec<Number> {
396    let n = nlp.borrow().n() as usize;
397    let (lo_map, up_map) = bound_maps(nlp);
398    let mut out = vec![0.0; n];
399    for (i, &pos) in lo_map.iter().enumerate() {
400        if let Some(&v) = z_l.get(i) {
401            out[pos] += v;
402        }
403    }
404    for (i, &pos) in up_map.iter().enumerate() {
405        if let Some(&v) = z_u.get(i) {
406            out[pos] -= v;
407        }
408    }
409    out
410}
411
412/// Inverse of [`pack_bound_multipliers`]: split a packed per-variable `λ_x`
413/// back into the IPM's compressed `(z_l, z_u)`.
414///
415/// The split is by sign — positive mass to the lower bound, negative to the
416/// upper — which is the only choice consistent with the sign restrictions
417/// `z_l ≥ 0`, `z_u ≥ 0` and with complementarity: away from a degenerate
418/// fixed variable at most one of the two can be nonzero, so the packing loses
419/// nothing to recover. A variable *fixed* by equal bounds is the one case
420/// where `λ_x` genuinely does not determine the pair; sign choice is the
421/// established convention there and the two are interchangeable in every
422/// downstream identity, since only their difference is ever used.
423///
424/// Mass that lands on a variable with no bound on the corresponding side has
425/// nowhere to go and is dropped — that can only happen if a caller hands in a
426/// multiplier violating the sign restrictions, and silently keeping it would
427/// corrupt the stationarity residual the user is shown.
428pub fn split_bound_multipliers(
429    nlp: &Rc<RefCell<dyn IpoptNlp>>,
430    lambda_x: &[Number],
431) -> (Vec<Number>, Vec<Number>) {
432    let (lo_map, up_map) = bound_maps(nlp);
433    let z_l = lo_map
434        .iter()
435        .map(|&pos| lambda_x.get(pos).copied().unwrap_or(0.0).max(0.0))
436        .collect();
437    let z_u = up_map
438        .iter()
439        .map(|&pos| (-lambda_x.get(pos).copied().unwrap_or(0.0)).max(0.0))
440        .collect();
441    (z_l, z_u)
442}
443
444/// Split the inequality multipliers `y_d` into the IPM's compressed slack
445/// bound multipliers `(v_l, v_u)`.
446///
447/// Stationarity of the barrier problem with respect to the slacks is
448/// `−y_d − v_l + v_u = 0`, i.e. `v_l − v_u = −y_d`; with `v_l, v_u ≥ 0` and
449/// complementarity that determines the pair up to the same degenerate case
450/// [`split_bound_multipliers`] documents. Needed when writing a point
451/// computed on the active-set side back onto an IPM iterate, whose slack
452/// duals would otherwise still describe the interior point.
453pub fn split_slack_multipliers(
454    nlp: &Rc<RefCell<dyn IpoptNlp>>,
455    y_d: &[Number],
456) -> (Vec<Number>, Vec<Number>) {
457    let b = nlp.borrow();
458    let idx = |m: &dyn pounce_linalg::Matrix| -> Vec<usize> {
459        m.as_any()
460            .downcast_ref::<ExpansionMatrix>()
461            .expect("pd_l / pd_u must be ExpansionMatrix")
462            .expanded_pos_indices()
463            .iter()
464            .map(|&p| p as usize)
465            .collect()
466    };
467    let lo_map = idx(&*b.pd_l());
468    let up_map = idx(&*b.pd_u());
469    let v_l = lo_map
470        .iter()
471        .map(|&pos| (-y_d.get(pos).copied().unwrap_or(0.0)).max(0.0))
472        .collect();
473    let v_u = up_map
474        .iter()
475        .map(|&pos| y_d.get(pos).copied().unwrap_or(0.0).max(0.0))
476        .collect();
477    (v_l, v_u)
478}