Skip to main content

pounce_nlp/
ipopt_nlp.rs

1//! NLP traits consumed by the algorithm core — port of `IpNLP.hpp` /
2//! `IpIpoptNLP.hpp`.
3//!
4//! These traits live in `pounce-nlp` (rather than `pounce-algorithm`)
5//! so that the concrete [`crate::orig_ipopt_nlp::OrigIpoptNlp`], which
6//! wraps a `TNLPAdapter` from this same crate, can implement them
7//! without forcing `pounce-nlp` to depend on `pounce-algorithm` (the
8//! reverse dependency already exists). `pounce-algorithm` re-exports
9//! both traits from its own `ipopt_nlp` module so the rest of the
10//! algorithm-side code continues to use the canonical
11//! `crate::ipopt_nlp::IpoptNlp` path.
12
13use pounce_common::types::{Index, Number};
14use pounce_linalg::{DenseVector, Matrix, SymMatrix, Vector};
15use std::rc::Rc;
16
17/// Human-readable names projected into the algorithm's *split* space —
18/// the index space the debugger reports residuals in, where equality and
19/// inequality constraints are separated and fixed variables are removed.
20///
21/// Each vector is indexed by the split-space position (`x_var[j]` is the
22/// `j`-th free variable, `eq[k]` the `k`-th equality constraint, `ineq[k]`
23/// the `k`-th inequality), and each entry is `Some(name)` when the model
24/// carried one or `None` to fall back to an index label. Producing this
25/// requires composing the TNLP's original-order names with the
26/// fixed-variable and c/d-split permutations, which is why it lives on
27/// the NLP rather than being read directly off the TNLP.
28///
29/// Names are what turn "variables 1, 132, 439 in equations 3, 15" into a
30/// model-level diagnosis — the gap Lee et al. (2024,
31/// <https://doi.org/10.69997/sct.147875>) call out for equation-oriented
32/// model debugging.
33#[derive(Debug, Clone, Default)]
34pub struct SplitNames {
35    /// Names of the free variables, in algorithm-side `x` order (`n()`).
36    pub x_var: Vec<Option<String>>,
37    /// Names of the equality constraints, in `c` order (`m_eq()`).
38    pub eq: Vec<Option<String>>,
39    /// Names of the inequality constraints, in `d` order (`m_ineq()`).
40    pub ineq: Vec<Option<String>>,
41}
42
43impl SplitNames {
44    /// Whether any entry carries a name. An all-`None` projection (e.g.
45    /// the model shipped no `.col`/`.row` files, or presolve declined to
46    /// forward names) is reported as "no names available" so the debugger
47    /// falls back to index labels rather than printing blanks.
48    pub fn any_present(&self) -> bool {
49        self.x_var
50            .iter()
51            .chain(self.eq.iter())
52            .chain(self.ineq.iter())
53            .any(Option::is_some)
54    }
55}
56
57/// Lower-level NLP interface (post-`TNLPAdapter`). Equality and
58/// inequality constraints are already separated; bounds are already
59/// classified into `x_l_map` / `x_u_map` / etc.
60///
61/// This is the equivalent of upstream `Ipopt::NLP`.
62pub trait Nlp {
63    fn n(&self) -> Index;
64    fn m_eq(&self) -> Index;
65    fn m_ineq(&self) -> Index;
66
67    fn eval_f(&mut self, x: &dyn Vector) -> Number;
68    fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector);
69    fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector);
70    fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector);
71    fn eval_jac_c(&mut self, x: &dyn Vector) -> Rc<dyn Matrix>;
72    fn eval_jac_d(&mut self, x: &dyn Vector) -> Rc<dyn Matrix>;
73    fn eval_h(
74        &mut self,
75        x: &dyn Vector,
76        obj_factor: Number,
77        y_c: &dyn Vector,
78        y_d: &dyn Vector,
79    ) -> Rc<dyn SymMatrix>;
80}
81
82/// Algorithm-side NLP (adds scaling-aware variants and provides the
83/// bound expansion matrices `Px_L`, `Px_U`, `Pd_L`, `Pd_U`). Mirrors
84/// upstream `Ipopt::IpoptNLP`.
85pub trait IpoptNlp: Nlp {
86    /// Per-evaluation call counts accumulated over the solve, ordered
87    /// `[f, grad_f, c, d, jac_c, jac_d, h]`. Populates the end-of-run
88    /// summary's evaluation tallies (#206). Default is all zeros for
89    /// implementors that do not count; [`OrigIpoptNlp`] reports its live
90    /// counters.
91    fn eval_counts(&self) -> [Index; 7] {
92        [0; 7]
93    }
94
95    fn x_l(&self) -> &dyn Vector;
96    fn x_u(&self) -> &dyn Vector;
97    fn d_l(&self) -> &dyn Vector;
98    fn d_u(&self) -> &dyn Vector;
99
100    /// Bound expansion matrices: `Px_L` extracts the
101    /// `x` components that have a finite lower bound, etc.
102    fn px_l(&self) -> Rc<dyn Matrix>;
103    fn px_u(&self) -> Rc<dyn Matrix>;
104    fn pd_l(&self) -> Rc<dyn Matrix>;
105    fn pd_u(&self) -> Rc<dyn Matrix>;
106
107    /// Replace the `x_L / x_U / d_L / d_U` bounds in place. Invoked by the
108    /// algorithm's accept step when the safe-slack mechanism moved one or
109    /// more bounds (port of `IpoptNLP::AdjustVariableBounds`,
110    /// `IpOrigIpoptNLP.cpp:990-1001`). Default is a no-op for NLP
111    /// implementations that do not own mutable bound storage.
112    fn adjust_variable_bounds(
113        &mut self,
114        _new_x_l: &dyn Vector,
115        _new_x_u: &dyn Vector,
116        _new_d_l: &dyn Vector,
117        _new_d_u: &dyn Vector,
118    ) {
119    }
120
121    /// Fill `x` with the initial primal values (mirrors upstream
122    /// `IpoptNLP::GetStartingPoint`'s `init_x` flag). Default impl
123    /// leaves `x` at its current contents (typically the zero vector
124    /// produced by `make_new`).
125    fn get_starting_x(&mut self, _x: &mut dyn Vector) -> bool {
126        true
127    }
128
129    /// Fill `y_c` / `y_d` with initial multiplier guesses (mirrors
130    /// `IpoptNLP::GetStartingPoint`'s `init_lambda` flag). Default
131    /// impl leaves them at their current contents (zeros).
132    fn get_starting_y(&mut self, _y_c: &mut dyn Vector, _y_d: &mut dyn Vector) -> bool {
133        true
134    }
135
136    /// Fill `z_l` / `z_u` / `v_l` / `v_u` with initial bound-multiplier
137    /// guesses (mirrors `init_z`). Default impl leaves them at zeros.
138    #[allow(clippy::too_many_arguments)]
139    fn get_starting_z(
140        &mut self,
141        _z_l: &mut dyn Vector,
142        _z_u: &mut dyn Vector,
143        _v_l: &mut dyn Vector,
144        _v_u: &mut dyn Vector,
145    ) -> bool {
146        true
147    }
148
149    /// Lift a compressed `x_var` (length `n_x_var`) to the full-x
150    /// length (`n_full_x` = user TNLP's `n`), splicing fixed-variable
151    /// values back in. Used at finalize-solution time to hand the user
152    /// a full-length x. Default impl returns x as-is, valid when the
153    /// problem has no fixed variables.
154    fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
155        let dx = x
156            .as_any()
157            .downcast_ref::<DenseVector>()
158            .expect("IpoptNlp::lift_x_to_full expects DenseVector");
159        dx.expanded_values().to_vec()
160    }
161
162    /// Pack the algorithm-side `(y_c, y_d)` constraint multipliers into
163    /// the user TNLP's `lambda` array (length `n_full_g`, ordered by
164    /// the original `g` index). Used by `GetIpoptCurrentIterate` and
165    /// `finalize_solution`. Default impl returns an empty vector — the
166    /// canonical `OrigIpoptNlp` implementation overrides it to perform
167    /// the c/d-split inverse and scaling unwind.
168    fn pack_lambda_for_user(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
169        Vec::new()
170    }
171
172    /// Pack the algorithm-side `(c, d)` constraint values into the user
173    /// TNLP's `g` array (length `n_full_g`, ordered by the original `g`
174    /// index, in user-unscaled space). Default impl returns an empty
175    /// vector; `OrigIpoptNlp` overrides.
176    fn pack_g_for_user(&self, _c: &dyn Vector, _d: &dyn Vector) -> Vec<Number> {
177        Vec::new()
178    }
179
180    /// Expand a compressed lower-bound-multiplier vector
181    /// (length = number of finite-lower-bound free variables) into the
182    /// user TNLP's full-`n` length `z_L` array. Default impl returns an
183    /// empty vector; `OrigIpoptNlp` overrides.
184    fn pack_z_l_for_user(&self, _z_l: &dyn Vector) -> Vec<Number> {
185        Vec::new()
186    }
187
188    /// Expand a compressed upper-bound-multiplier vector into the user
189    /// TNLP's full-`n` length `z_U` array. Default impl returns an
190    /// empty vector; `OrigIpoptNlp` overrides.
191    fn pack_z_u_for_user(&self, _z_u: &dyn Vector) -> Vec<Number> {
192        Vec::new()
193    }
194
195    /// Number of variables `n` as the user TNLP declared it (= `n_full_x`,
196    /// before fixed-variable elimination). Used by inspector entry
197    /// points that need to size full-`n` buffers. Default impl returns
198    /// 0; `OrigIpoptNlp` overrides.
199    fn n_full_x(&self) -> Index {
200        0
201    }
202
203    /// Number of constraints `m` as the user TNLP declared it (= `n_full_g`).
204    /// Default impl returns 0; `OrigIpoptNlp` overrides.
205    fn n_full_g(&self) -> Index {
206        0
207    }
208
209    /// Lift the algorithm-side `(y_c, y_d)` multipliers back to the
210    /// user TNLP's `lambda` array (length `m_full = n_c + n_d`),
211    /// matching upstream `IpOrigIpoptNLP::FinalizeSolution`. Sibling
212    /// to `pack_lambda_for_user`; added by pounce#11 for the
213    /// `finalize_solution` path. Default returns empty; `OrigIpoptNlp`
214    /// overrides.
215    fn finalize_solution_lambda(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
216        Vec::new()
217    }
218
219    /// Lift compressed `z_l` back to full-x. Sibling to
220    /// `pack_z_l_for_user`; added by pounce#11. Default returns empty.
221    fn finalize_solution_z_l(&self, _z_l: &dyn Vector) -> Vec<Number> {
222        Vec::new()
223    }
224
225    /// Lift compressed `z_u` back to full-x. Sibling to
226    /// `pack_z_u_for_user`; added by pounce#11. Default returns empty.
227    fn finalize_solution_z_u(&self, _z_u: &dyn Vector) -> Vec<Number> {
228        Vec::new()
229    }
230
231    /// Map a 0-based **full-x** index (user-TNLP space, length
232    /// `n_full_x()`) to a 0-based **var-x** index (algorithm-side,
233    /// length `n()`). Returns `None` when the variable was eliminated
234    /// because `x_l[i] == x_u[i]` under
235    /// `fixed_variable_treatment = make_parameter`.
236    ///
237    /// Default impl assumes no fixed variables (identity mapping). The
238    /// `OrigIpoptNlp` implementation consults
239    /// `BoundClassification::full_to_var`.
240    fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
241        Some(full_idx)
242    }
243
244    /// Map a 0-based **full-g** index (user-TNLP space, length
245    /// `n_full_g()`) to a 0-based position in the c-block (algorithm-side
246    /// equality multiplier vector `y_c`, length `m_eq()`). Returns
247    /// `None` when the constraint is an inequality (lives in `d`, not
248    /// `c`).
249    ///
250    /// Default impl assumes the c-block matches the user's g order
251    /// (no c/d split); `OrigIpoptNlp` overrides via
252    /// `BoundClassification::c_map`.
253    fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
254        Some(full_idx)
255    }
256
257    /// Inverse of [`Self::full_x_to_var_x`]: map a 0-based var-x index
258    /// (length `n()`) to the corresponding full-x index (length
259    /// `n_full_x()`). Used when scattering a compressed step or
260    /// iterate back into the user's full-x array.
261    ///
262    /// Default impl assumes no fixed variables (identity); `OrigIpoptNlp`
263    /// returns `classification.x_not_fixed_map[var_idx]`.
264    fn var_x_to_full_x(&self, var_idx: Index) -> Index {
265        var_idx
266    }
267
268    /// Effective objective scaling factor (`df_` upstream): the value
269    /// `f` is multiplied by inside [`Self::eval_f`]. Used to recover the
270    /// unscaled objective for display. Default `1.0` (no scaling);
271    /// `OrigIpoptNlp` overrides.
272    fn obj_scaling_factor(&self) -> Number {
273        1.0
274    }
275
276    /// The **solver-computed** part of the objective scale, before the user's
277    /// constant `obj_scaling_factor` is multiplied in.
278    ///
279    /// [`Self::obj_scaling_factor`] returns the product `df * user_factor`,
280    /// which is the right thing for unscaling a residual but the wrong thing
281    /// for asking *why* the scale is small. `df` is what gradient-based scaling
282    /// computed and clamped at `nlp_scaling_min_value`; the user factor is a
283    /// deliberate choice. Only the former can mask a certificate (gh #200), so
284    /// the termination logic keys on this rather than on the product.
285    /// Default `1.0`; `OrigIpoptNlp` overrides.
286    fn computed_obj_scaling_factor(&self) -> Number {
287        1.0
288    }
289
290    /// Per-row scaling vector for the equality block (`dc_` upstream):
291    /// the factor each `c` row is multiplied by inside [`Self::eval_c`]
292    /// / [`Self::eval_jac_c`]. `None` ⇔ no row scaling (all 1.0);
293    /// length `m_eq()` when present. Together with
294    /// [`Self::obj_scaling_factor`] and [`Self::d_scale_vec`] this is
295    /// what lets `pounce-sensitivity` undo the NLP scaling baked into
296    /// the converged KKT factor (pounce#128). Default `None`;
297    /// `OrigIpoptNlp` overrides.
298    fn c_scale_vec(&self) -> Option<Vec<Number>> {
299        None
300    }
301
302    /// Per-row scaling vector for the inequality block (`dd_`
303    /// upstream), same convention as [`Self::c_scale_vec`]. Length
304    /// `m_ineq()` when present. Default `None`; `OrigIpoptNlp`
305    /// overrides.
306    fn d_scale_vec(&self) -> Option<Vec<Number>> {
307        None
308    }
309
310    /// Human-readable variable / constraint names projected into the
311    /// algorithm's split space (free variables, equalities, inequalities),
312    /// or `None` when the model carries no names. The debugger uses this to
313    /// label residuals by model name (`mass_balance`) rather than index
314    /// (`c[3]`) — see [`SplitNames`] and Lee et al. (2024,
315    /// <https://doi.org/10.69997/sct.147875>).
316    ///
317    /// Default returns `None`; `OrigIpoptNlp` overrides by pulling
318    /// `idx_names` metadata from the underlying TNLP and composing it with
319    /// the bound / c-d-split permutations.
320    fn split_space_names(&self) -> Option<SplitNames> {
321        None
322    }
323}