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 /// The *declared* compressed inequality bounds `(d_L, d_U)`, in the same
101 /// (internally scaled) space as [`Self::d_l`] / [`Self::d_u`] but without
102 /// the `bound_relax_factor` widening or safe-slack adjustments the live
103 /// vectors carry. The scale-relative feasibility measure keys row
104 /// magnitudes off these: on the live vector a relaxed zero bound reads as
105 /// `~1e-8`, fabricating a magnitude for a row that has none. `None` (the
106 /// default) means "not tracked" — callers should fall back to the live
107 /// bounds.
108 fn declared_d_bounds(&self) -> Option<(Vec<Number>, Vec<Number>)> {
109 None
110 }
111
112 /// The *declared* equality right-hand sides `b` — the pre-fold constants
113 /// subtracted to turn `g_i(x) == b_i` into the algorithm's residual
114 /// `c_i(x) = 0` — reported in the same (internally scaled) space as
115 /// [`Nlp::eval_c`]'s output, so `|c_i| / |b_i|` is a pure ratio.
116 ///
117 /// The fold is exactly what erases the row's magnitude: `|c_i|` *is* the
118 /// violation and carries no independent scale, so a scale-relative
119 /// feasibility measure has nothing to divide by unless the RHS is plumbed
120 /// back. Same "declared, not live" contract as [`Self::declared_d_bounds`]:
121 /// the value is the one the user wrote (times any row scaling the solver
122 /// itself applied), never a relaxed or otherwise adjusted stand-in.
123 ///
124 /// `None` (the default) means "not tracked" — callers must then abstain
125 /// from any relative verdict on the `c` block rather than substitute a
126 /// magnitude of their own.
127 fn declared_c_rhs(&self) -> Option<Vec<Number>> {
128 None
129 }
130
131 /// Bound expansion matrices: `Px_L` extracts the
132 /// `x` components that have a finite lower bound, etc.
133 fn px_l(&self) -> Rc<dyn Matrix>;
134 fn px_u(&self) -> Rc<dyn Matrix>;
135 fn pd_l(&self) -> Rc<dyn Matrix>;
136 fn pd_u(&self) -> Rc<dyn Matrix>;
137
138 /// Replace the `x_L / x_U / d_L / d_U` bounds in place. Invoked by the
139 /// algorithm's accept step when the safe-slack mechanism moved one or
140 /// more bounds (port of `IpoptNLP::AdjustVariableBounds`,
141 /// `IpOrigIpoptNLP.cpp:990-1001`). Default is a no-op for NLP
142 /// implementations that do not own mutable bound storage.
143 fn adjust_variable_bounds(
144 &mut self,
145 _new_x_l: &dyn Vector,
146 _new_x_u: &dyn Vector,
147 _new_d_l: &dyn Vector,
148 _new_d_u: &dyn Vector,
149 ) {
150 }
151
152 /// Fill `x` with the initial primal values (mirrors upstream
153 /// `IpoptNLP::GetStartingPoint`'s `init_x` flag). Default impl
154 /// leaves `x` at its current contents (typically the zero vector
155 /// produced by `make_new`).
156 fn get_starting_x(&mut self, _x: &mut dyn Vector) -> bool {
157 true
158 }
159
160 /// Prepare a complete primal-dual starting-point snapshot for a warm
161 /// start. The default is a no-op for NLP implementations that do not
162 /// route through a TNLP callback.
163 ///
164 /// The warm-start initializer calls this once before its separate
165 /// `get_starting_x` / `get_starting_y` / `get_starting_z` projections.
166 /// Implementations can therefore fetch all requested data in one callback,
167 /// matching Ipopt's single `GetStartingPoint` call.
168 fn prepare_warm_start(&mut self) -> bool {
169 true
170 }
171
172 /// Release any temporary state prepared for the warm-start projections.
173 ///
174 /// Called once the initializer has obtained its `x`, `y`, and `z` blocks.
175 /// The default is a no-op; adapters that cache a TNLP callback payload use
176 /// this to keep that snapshot scoped to one initialization only.
177 fn finish_warm_start(&mut self) {}
178
179 /// Fill `y_c` / `y_d` with initial multiplier guesses (mirrors
180 /// `IpoptNLP::GetStartingPoint`'s `init_lambda` flag). Default
181 /// impl leaves them at their current contents (zeros).
182 fn get_starting_y(&mut self, _y_c: &mut dyn Vector, _y_d: &mut dyn Vector) -> bool {
183 true
184 }
185
186 /// Fill `z_l` / `z_u` / `v_l` / `v_u` with initial bound-multiplier
187 /// guesses (mirrors `init_z`). Default impl leaves them at zeros.
188 #[allow(clippy::too_many_arguments)]
189 fn get_starting_z(
190 &mut self,
191 _z_l: &mut dyn Vector,
192 _z_u: &mut dyn Vector,
193 _v_l: &mut dyn Vector,
194 _v_u: &mut dyn Vector,
195 ) -> bool {
196 true
197 }
198
199 /// Lift a compressed `x_var` (length `n_x_var`) to the full-x
200 /// length (`n_full_x` = user TNLP's `n`), splicing fixed-variable
201 /// values back in. Used at finalize-solution time to hand the user
202 /// a full-length x. Default impl returns x as-is, valid when the
203 /// problem has no fixed variables.
204 fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
205 let dx = x
206 .as_any()
207 .downcast_ref::<DenseVector>()
208 .expect("IpoptNlp::lift_x_to_full expects DenseVector");
209 dx.expanded_values().to_vec()
210 }
211
212 /// The full-x to hand `TNLP::finalize_solution`: [`Self::lift_x_to_full`],
213 /// plus whatever the reported point owes the user that the working
214 /// iterate does not — today, the `honor_original_bounds` projection
215 /// back into the declared box (the `bound_relax_factor` widening
216 /// otherwise reports a bound-pinned solution just outside its own
217 /// bounds). Default impl is `lift_x_to_full`; `OrigIpoptNlp`
218 /// overrides.
219 fn finalize_solution_x(&self, x: &dyn Vector) -> Vec<Number> {
220 self.lift_x_to_full(x)
221 }
222
223 /// Pack the algorithm-side `(y_c, y_d)` constraint multipliers into
224 /// the user TNLP's `lambda` array (length `n_full_g`, ordered by
225 /// the original `g` index). Used by `GetIpoptCurrentIterate` and
226 /// `finalize_solution`. Default impl returns an empty vector — the
227 /// canonical `OrigIpoptNlp` implementation overrides it to perform
228 /// the c/d-split inverse and scaling unwind.
229 fn pack_lambda_for_user(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
230 Vec::new()
231 }
232
233 /// Pack the algorithm-side `(c, d)` constraint values into the user
234 /// TNLP's `g` array (length `n_full_g`, ordered by the original `g`
235 /// index, in user-unscaled space). Default impl returns an empty
236 /// vector; `OrigIpoptNlp` overrides.
237 fn pack_g_for_user(&self, _c: &dyn Vector, _d: &dyn Vector) -> Vec<Number> {
238 Vec::new()
239 }
240
241 /// Expand a compressed lower-bound-multiplier vector
242 /// (length = number of finite-lower-bound free variables) into the
243 /// user TNLP's full-`n` length `z_L` array. Default impl returns an
244 /// empty vector; `OrigIpoptNlp` overrides.
245 fn pack_z_l_for_user(&self, _z_l: &dyn Vector) -> Vec<Number> {
246 Vec::new()
247 }
248
249 /// Expand a compressed upper-bound-multiplier vector into the user
250 /// TNLP's full-`n` length `z_U` array. Default impl returns an
251 /// empty vector; `OrigIpoptNlp` overrides.
252 fn pack_z_u_for_user(&self, _z_u: &dyn Vector) -> Vec<Number> {
253 Vec::new()
254 }
255
256 /// Number of variables `n` as the user TNLP declared it (= `n_full_x`,
257 /// before fixed-variable elimination). Used by inspector entry
258 /// points that need to size full-`n` buffers. Default impl returns
259 /// 0; `OrigIpoptNlp` overrides.
260 fn n_full_x(&self) -> Index {
261 0
262 }
263
264 /// Number of constraints `m` as the user TNLP declared it (= `n_full_g`).
265 /// Default impl returns 0; `OrigIpoptNlp` overrides.
266 fn n_full_g(&self) -> Index {
267 0
268 }
269
270 /// Lift the algorithm-side `(y_c, y_d)` multipliers back to the
271 /// user TNLP's `lambda` array (length `m_full = n_c + n_d`),
272 /// matching upstream `IpOrigIpoptNLP::FinalizeSolution`. Sibling
273 /// to `pack_lambda_for_user`; added by pounce#11 for the
274 /// `finalize_solution` path. Default returns empty; `OrigIpoptNlp`
275 /// overrides.
276 fn finalize_solution_lambda(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
277 Vec::new()
278 }
279
280 /// Lift compressed `z_l` back to full-x. Sibling to
281 /// `pack_z_l_for_user`; added by pounce#11. Default returns empty.
282 fn finalize_solution_z_l(&self, _z_l: &dyn Vector) -> Vec<Number> {
283 Vec::new()
284 }
285
286 /// Lift compressed `z_u` back to full-x. Sibling to
287 /// `pack_z_u_for_user`; added by pounce#11. Default returns empty.
288 fn finalize_solution_z_u(&self, _z_u: &dyn Vector) -> Vec<Number> {
289 Vec::new()
290 }
291
292 /// Map a 0-based **full-x** index (user-TNLP space, length
293 /// `n_full_x()`) to a 0-based **var-x** index (algorithm-side,
294 /// length `n()`). Returns `None` when the variable was eliminated
295 /// because `x_l[i] == x_u[i]` under
296 /// `fixed_variable_treatment = make_parameter`.
297 ///
298 /// Default impl assumes no fixed variables (identity mapping). The
299 /// `OrigIpoptNlp` implementation consults
300 /// `BoundClassification::full_to_var`.
301 fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
302 Some(full_idx)
303 }
304
305 /// Map a 0-based **full-g** index (user-TNLP space, length
306 /// `n_full_g()`) to a 0-based position in the c-block (algorithm-side
307 /// equality multiplier vector `y_c`, length `m_eq()`). Returns
308 /// `None` when the constraint is an inequality (lives in `d`, not
309 /// `c`).
310 ///
311 /// Default impl assumes the c-block matches the user's g order
312 /// (no c/d split); `OrigIpoptNlp` overrides via
313 /// `BoundClassification::c_map`.
314 fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
315 Some(full_idx)
316 }
317
318 /// Inverse of [`Self::full_x_to_var_x`]: map a 0-based var-x index
319 /// (length `n()`) to the corresponding full-x index (length
320 /// `n_full_x()`). Used when scattering a compressed step or
321 /// iterate back into the user's full-x array.
322 ///
323 /// Default impl assumes no fixed variables (identity); `OrigIpoptNlp`
324 /// returns `classification.x_not_fixed_map[var_idx]`.
325 fn var_x_to_full_x(&self, var_idx: Index) -> Index {
326 var_idx
327 }
328
329 /// Effective objective scaling factor (`df_` upstream): the value
330 /// `f` is multiplied by inside [`Self::eval_f`]. Used to recover the
331 /// unscaled objective for display. Default `1.0` (no scaling);
332 /// `OrigIpoptNlp` overrides.
333 fn obj_scaling_factor(&self) -> Number {
334 1.0
335 }
336
337 /// The **solver-computed** part of the objective scale, before the user's
338 /// constant `obj_scaling_factor` is multiplied in.
339 ///
340 /// [`Self::obj_scaling_factor`] returns the product `df * user_factor`,
341 /// which is the right thing for unscaling a residual but the wrong thing
342 /// for asking *why* the scale is small. `df` is what gradient-based scaling
343 /// computed and clamped at `nlp_scaling_min_value`; the user factor is a
344 /// deliberate choice. Only the former can mask a certificate (gh #200), so
345 /// the termination logic keys on this rather than on the product.
346 /// Default `1.0`; `OrigIpoptNlp` overrides.
347 fn computed_obj_scaling_factor(&self) -> Number {
348 1.0
349 }
350
351 /// Per-row scaling vector for the equality block (`dc_` upstream):
352 /// the factor each `c` row is multiplied by inside [`Self::eval_c`]
353 /// / [`Self::eval_jac_c`]. `None` ⇔ no row scaling (all 1.0);
354 /// length `m_eq()` when present. Together with
355 /// [`Self::obj_scaling_factor`] and [`Self::d_scale_vec`] this is
356 /// what lets `pounce-sensitivity` undo the NLP scaling baked into
357 /// the converged KKT factor (pounce#128). Default `None`;
358 /// `OrigIpoptNlp` overrides.
359 fn c_scale_vec(&self) -> Option<Vec<Number>> {
360 None
361 }
362
363 /// Per-row scaling vector for the inequality block (`dd_`
364 /// upstream), same convention as [`Self::c_scale_vec`]. Length
365 /// `m_ineq()` when present. Default `None`; `OrigIpoptNlp`
366 /// overrides.
367 fn d_scale_vec(&self) -> Option<Vec<Number>> {
368 None
369 }
370
371 /// The per-variable factors `d` a scaling wrapper below this NLP
372 /// applied as a change of variables `x̃ = d ⊙ x` (gh#486). `None`
373 /// ⇔ no variable scaling; length [`Self::n_full_x`] when present,
374 /// i.e. the **full-x** space of the TNLP that was submitted, before
375 /// fixed variables were dropped.
376 ///
377 /// This is the x-axis counterpart of [`Self::obj_scaling_factor`] /
378 /// [`Self::c_scale_vec`] / [`Self::d_scale_vec`], and it exists for
379 /// the same reason: a consumer reading the converged KKT system
380 /// rather than the `finalize_solution` payload is looking at `x̃`,
381 /// not `x`, and needs the factors to say so. Unlike the other
382 /// three, the substitution happens *below* the NLP — in
383 /// `ScalingTnlp` — so this only forwards what the TNLP reports.
384 /// Default `None`; `OrigIpoptNlp` overrides.
385 fn variable_scaling(&self) -> Option<Vec<Number>> {
386 None
387 }
388
389 /// Human-readable variable / constraint names projected into the
390 /// algorithm's split space (free variables, equalities, inequalities),
391 /// or `None` when the model carries no names. The debugger uses this to
392 /// label residuals by model name (`mass_balance`) rather than index
393 /// (`c[3]`) — see [`SplitNames`] and Lee et al. (2024,
394 /// <https://doi.org/10.69997/sct.147875>).
395 ///
396 /// Default returns `None`; `OrigIpoptNlp` overrides by pulling
397 /// `idx_names` metadata from the underlying TNLP and composing it with
398 /// the bound / c-d-split permutations.
399 fn split_space_names(&self) -> Option<SplitNames> {
400 None
401 }
402}