pounce_nlp/tnlp.rs
1//! User-facing `TNLP` trait — port of `Interfaces/IpTNLP.{hpp,cpp}`.
2//!
3//! The Rust shape replaces upstream's two-call `(iRow,jCol,values)`
4//! convention with [`SparsityRequest`], a request enum carrying the
5//! caller-supplied buffers. This is more typesafe (no NULL pointers,
6//! buffer length is type-checked) and matches the eight-method API
7//! upstream documents.
8//!
9//! The `IpoptData` / `IpoptCalculatedQuantities` / `IteratesVector`
10//! parameters of `intermediate_callback` and `finalize_solution` are
11//! introduced as opaque [`IpoptData`] / [`IpoptCq`] types; their full
12//! field set lands in Phase 5.
13//!
14//! Trait objects: `dyn TNLP` is supported. Concrete callers store the
15//! TNLP behind an `Rc<RefCell<dyn TNLP>>` (so eval methods can mutate
16//! internal caches) — `pounce_algorithm::IpoptApplication` handles
17//! wrapping.
18
19use crate::alg_types::SolverReturn;
20use crate::return_codes::AlgorithmMode;
21use pounce_common::types::{Index, Number};
22use std::collections::BTreeMap;
23
24/// Linearity tags. Mirrors `TNLP::LinearityType` upstream.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Linearity {
27 Linear,
28 NonLinear,
29}
30
31/// Index style for triplet I/O. Mirrors `TNLP::IndexStyleEnum`.
32/// `Fortran` (1-based) is what MUMPS / HSL want directly; `C`
33/// (0-based) is more natural for Rust user code.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum IndexStyle {
36 C = 0,
37 Fortran = 1,
38}
39
40/// Problem dimensions returned by [`TNLP::get_nlp_info`].
41#[derive(Debug, Clone, Copy)]
42pub struct NlpInfo {
43 pub n: Index,
44 pub m: Index,
45 pub nnz_jac_g: Index,
46 pub nnz_h_lag: Index,
47 pub index_style: IndexStyle,
48}
49
50/// Variable / constraint metadata buckets, mirroring upstream's
51/// `(StringMetaDataMapType, IntegerMetaDataMapType, NumericMetaDataMapType)`.
52#[derive(Debug, Default, Clone)]
53pub struct MetaData {
54 pub strings: BTreeMap<String, Vec<String>>,
55 pub integers: BTreeMap<String, Vec<Index>>,
56 pub numerics: BTreeMap<String, Vec<Number>>,
57}
58
59/// Conventional [`MetaData::strings`] key for per-index human-readable
60/// names (one entry per variable, or per constraint, in original
61/// problem order). Mirrors upstream Ipopt's `"idx_names"` metadata
62/// key. Carrying names this far lets the debugger report a near-singular
63/// Jacobian row as the `mass_balance` equation instead of "row 3" —
64/// the model-vs-index gap Lee et al. (2024,
65/// <https://doi.org/10.69997/sct.147875>) flag as a key roadblock for
66/// debugging equation-oriented models.
67pub const IDX_NAMES: &str = "idx_names";
68
69/// Bound-data target buffers passed into [`TNLP::get_bounds_info`].
70#[derive(Debug)]
71pub struct BoundsInfo<'a> {
72 pub x_l: &'a mut [Number],
73 pub x_u: &'a mut [Number],
74 pub g_l: &'a mut [Number],
75 pub g_u: &'a mut [Number],
76}
77
78/// Starting-point target buffers passed into [`TNLP::get_starting_point`].
79/// Each `init_*` flag matches upstream — mostly false unless warm-starting.
80#[derive(Debug)]
81pub struct StartingPoint<'a> {
82 pub init_x: bool,
83 pub x: &'a mut [Number],
84 pub init_z: bool,
85 pub z_l: &'a mut [Number],
86 pub z_u: &'a mut [Number],
87 pub init_lambda: bool,
88 pub lambda: &'a mut [Number],
89}
90
91/// Scaling-factor target buffers passed into [`TNLP::get_scaling_parameters`].
92#[derive(Debug)]
93pub struct ScalingRequest<'a> {
94 pub obj_scaling: &'a mut Number,
95 pub use_x_scaling: &'a mut bool,
96 pub x_scaling: &'a mut [Number],
97 pub use_g_scaling: &'a mut bool,
98 pub g_scaling: &'a mut [Number],
99}
100
101/// Mode discriminator for the structure / values calls of
102/// [`TNLP::eval_jac_g`] and [`TNLP::eval_h`]. Replaces upstream's
103/// `iRow != NULL` heuristic.
104#[derive(Debug)]
105pub enum SparsityRequest<'a> {
106 /// First call: fill `irow` and `jcol` with the structure (the
107 /// numbering style is whatever was returned in
108 /// [`NlpInfo::index_style`]). The values array is absent.
109 Structure {
110 irow: &'a mut [Index],
111 jcol: &'a mut [Index],
112 },
113 /// Subsequent calls: fill `values` with the entries of the matrix
114 /// at the current `x` (and, for the Hessian, `lambda`,
115 /// `obj_factor`).
116 Values { values: &'a mut [Number] },
117}
118
119/// Solution as passed to [`TNLP::finalize_solution`].
120#[derive(Debug)]
121pub struct Solution<'a> {
122 pub status: SolverReturn,
123 pub x: &'a [Number],
124 pub z_l: &'a [Number],
125 pub z_u: &'a [Number],
126 pub g: &'a [Number],
127 pub lambda: &'a [Number],
128 pub obj_value: Number,
129}
130
131/// Per-iteration callback payload for [`TNLP::intermediate_callback`].
132#[derive(Debug, Clone, Copy)]
133pub struct IterStats {
134 pub mode: AlgorithmMode,
135 pub iter: Index,
136 pub obj_value: Number,
137 pub inf_pr: Number,
138 pub inf_du: Number,
139 pub mu: Number,
140 pub d_norm: Number,
141 pub regularization_size: Number,
142 pub alpha_du: Number,
143 pub alpha_pr: Number,
144 pub ls_trials: Index,
145}
146
147/// Forward-declared placeholder for `IpoptData`. Phase 5 fills this
148/// in with the full mutable iterate-state structure; for Phase 3 it
149/// is opaque.
150#[derive(Debug, Default)]
151pub struct IpoptData {
152 _private: (),
153}
154
155/// Forward-declared placeholder for `IpoptCalculatedQuantities`.
156/// Phase 5 fills this in.
157#[derive(Debug, Default)]
158pub struct IpoptCq {
159 _private: (),
160}
161
162/// User-facing NLP interface — port of `class TNLP`. Object-safe.
163///
164/// Defaults provided for every method that upstream documents as
165/// "default returns false / does nothing", so simple problems only
166/// override the eight pure-virtual methods.
167pub trait TNLP {
168 /// **Required.** Problem dimensions and triplet index style.
169 fn get_nlp_info(&mut self) -> Option<NlpInfo>;
170
171 /// **Required.** Variable / constraint bounds.
172 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool;
173
174 /// **Required.** Initial primal (and optionally dual) point.
175 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool;
176
177 /// **Required.** Objective value at `x`.
178 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number>;
179
180 /// **Required.** Objective gradient at `x` into `grad_f`.
181 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool;
182
183 /// **Required.** Constraint values `g(x)`.
184 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool;
185
186 /// **Required.** Jacobian of `g`. Sparsity vs. values selected by
187 /// `mode`. `x` and `new_x` are unused on the structure call.
188 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool;
189
190 /// **Required for exact Hessian, optional for L-BFGS.** Hessian
191 /// of the Lagrangian. Default returns false (signals to %Ipopt
192 /// that quasi-Newton must be used).
193 fn eval_h(
194 &mut self,
195 _x: Option<&[Number]>,
196 _new_x: bool,
197 _obj_factor: Number,
198 _lambda: Option<&[Number]>,
199 _new_lambda: bool,
200 _mode: SparsityRequest<'_>,
201 ) -> bool {
202 false
203 }
204
205 /// **Required.** Receives the final iterate after solve.
206 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq);
207
208 // ---- Optional methods (defaults match upstream's "do nothing") ----
209
210 /// Provide variable/constraint metadata (e.g. `idx_names`).
211 /// Default: no metadata.
212 fn get_var_con_metadata(&mut self, _var: &mut MetaData, _con: &mut MetaData) -> bool {
213 false
214 }
215
216 /// User-supplied scaling, used only when
217 /// `nlp_scaling_method=user-scaling`. Default: declines.
218 fn get_scaling_parameters(&mut self, _req: ScalingRequest<'_>) -> bool {
219 false
220 }
221
222 /// Variable linearity tags (used by Bonmin, not by Ipopt).
223 fn get_variables_linearity(&mut self, _types: &mut [Linearity]) -> bool {
224 false
225 }
226
227 /// Per-variable linearity with respect to the **objective only** (a
228 /// pounce extension; upstream has no objective-scoped query).
229 /// `NonLinear` iff the objective's nonlinear part depends on the
230 /// variable; a variable that enters the objective only linearly (or
231 /// not at all) is `Linear` even when it is nonlinear in a
232 /// constraint. Consumed by presolve's Phase-0 objective-coupling
233 /// guard, which must not mistake constraint-only nonlinearity for
234 /// objective coupling. Default: declines (slice untouched).
235 fn get_objective_variables_linearity(&mut self, _types: &mut [Linearity]) -> bool {
236 false
237 }
238
239 /// Constraint linearity tags. Used by adaptive-mu's
240 /// `nlp_scaling_method=equilibration-based`.
241 fn get_constraints_linearity(&mut self, _types: &mut [Linearity]) -> bool {
242 false
243 }
244
245 /// Number of variables that appear nonlinearly. Returning -1
246 /// means "treat all as nonlinear" (the Ipopt default).
247 fn get_number_of_nonlinear_variables(&mut self) -> Index {
248 -1
249 }
250
251 /// List of nonlinear variable indices, in the index style
252 /// returned from [`Self::get_nlp_info`].
253 fn get_list_of_nonlinear_variables(&mut self, _pos_nonlin_vars: &mut [Index]) -> bool {
254 false
255 }
256
257 /// Per-iteration intermediate callback. Returning false requests
258 /// early termination with `User_Requested_Stop`.
259 fn intermediate_callback(
260 &mut self,
261 _stats: IterStats,
262 _ip_data: &IpoptData,
263 _ip_cq: &IpoptCq,
264 ) -> bool {
265 true
266 }
267
268 /// Final metadata pass — called just before
269 /// [`Self::finalize_solution`]. Default does nothing.
270 fn finalize_metadata(&mut self, _var: &MetaData, _con: &MetaData) {}
271
272 /// Whether this TNLP is already an explicit generic-presolve wrapper.
273 ///
274 /// This lets the application preserve the public `wrap_with_presolve`
275 /// workflow when `presolve=yes` is also present in its options. Ordinary
276 /// TNLPs and unrelated decorators return `false`.
277 ///
278 /// A transparent decorator around another TNLP should override this and
279 /// forward `inner.borrow().is_presolve_wrapper()`. Otherwise the public
280 /// solve entry point cannot see a presolve wrapper below the decorator and
281 /// may add a second one.
282 fn is_presolve_wrapper(&self) -> bool {
283 false
284 }
285
286 /// The per-variable scaling factors this decorator applies, if it
287 /// is a scaling wrapper (gh#486). Consumers that read the
288 /// algorithm's iterate rather than the `finalize_solution` payload
289 /// see scaled coordinates and need these to undo the substitution.
290 /// A transparent decorator should forward the inner answer.
291 fn scaling_factors(&self) -> Option<Vec<pounce_common::types::Number>> {
292 None
293 }
294
295 /// A *proof* that this problem has no feasible point, if presolve found
296 /// one. `None` (the default) means "not proved" — which is not the same as
297 /// "feasible".
298 ///
299 /// This is the channel presolve previously lacked. Bound propagation and
300 /// FBBT can both establish emptiness of the feasible region **exactly**,
301 /// but with nowhere to report it they discarded the result and let the IPM
302 /// re-derive a strictly weaker numerical verdict — a stationary point of
303 /// the constraint violation, which for a nonconvex problem proves nothing
304 /// globally. Surfacing the proof lets the solver distinguish *"proved
305 /// infeasible"* from *"converged to a locally infeasible point"*.
306 ///
307 /// A transparent decorator around another TNLP should override this and
308 /// forward `inner.borrow().presolve_infeasibility_proof()`, for the same
309 /// reason [`Self::is_presolve_wrapper`] must be forwarded.
310 fn presolve_infeasibility_proof(&self) -> Option<InfeasibilityProof> {
311 None
312 }
313}
314
315/// How presolve established that the feasible region is empty.
316///
317/// Both variants are *proofs*, not heuristics — see the per-variant notes for
318/// why each is sound in floating point. Anything less than a proof must not be
319/// reported here; the numerical "converged to a locally infeasible point"
320/// verdict has its own path.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum InfeasibilityProof {
323 /// Linear bound propagation drove some variable's bounds past each other
324 /// (`x_l[j] > x_u[j]`). Over a box, propagating a linear row is exact, and
325 /// the crossing must exceed a `1e-12` margin before it counts — so the
326 /// test errs toward *not* declaring infeasibility.
327 BoundPropagation,
328 /// FBBT interval arithmetic emptied the feasible range of constraint
329 /// `witness`. Sound because every interval operation is **outward
330 /// rounded** (one ULP out on each side), so the computed interval always
331 /// contains the true range: an empty computed interval means the true
332 /// range is empty too.
333 IntervalArithmetic {
334 /// Index of the constraint whose range was proved empty.
335 witness: usize,
336 },
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 /// Tiny `min x[0]^2 + x[1]^2 s.t. x[0] + x[1] = 1` problem.
344 /// Used as a smoke test that the trait is object-safe and the
345 /// defaults compile.
346 struct Mini;
347 impl TNLP for Mini {
348 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
349 Some(NlpInfo {
350 n: 2,
351 m: 1,
352 nnz_jac_g: 2,
353 nnz_h_lag: 2,
354 index_style: IndexStyle::C,
355 })
356 }
357 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
358 b.x_l.iter_mut().for_each(|v| *v = -1e19);
359 b.x_u.iter_mut().for_each(|v| *v = 1e19);
360 b.g_l[0] = 1.0;
361 b.g_u[0] = 1.0;
362 true
363 }
364 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
365 assert!(sp.init_x);
366 sp.x[0] = 0.5;
367 sp.x[1] = 0.5;
368 true
369 }
370 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
371 Some(x[0] * x[0] + x[1] * x[1])
372 }
373 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
374 grad_f[0] = 2.0 * x[0];
375 grad_f[1] = 2.0 * x[1];
376 true
377 }
378 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
379 g[0] = x[0] + x[1];
380 true
381 }
382 fn eval_jac_g(
383 &mut self,
384 _x: Option<&[Number]>,
385 _new_x: bool,
386 mode: SparsityRequest<'_>,
387 ) -> bool {
388 match mode {
389 SparsityRequest::Structure { irow, jcol } => {
390 irow.copy_from_slice(&[0, 0]);
391 jcol.copy_from_slice(&[0, 1]);
392 }
393 SparsityRequest::Values { values } => {
394 values.copy_from_slice(&[1.0, 1.0]);
395 }
396 }
397 true
398 }
399 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
400 }
401
402 #[test]
403 fn tnlp_is_object_safe() {
404 // The trait must be usable behind `dyn`; this also exercises
405 // every default-impl method to make sure they compile.
406 let mut t: Box<dyn TNLP> = Box::new(Mini);
407 let info = t.get_nlp_info().expect("get_nlp_info");
408 assert_eq!(info.n, 2);
409 assert_eq!(info.m, 1);
410 assert_eq!(info.index_style, IndexStyle::C);
411
412 let mut x_l = [0.0; 2];
413 let mut x_u = [0.0; 2];
414 let mut g_l = [0.0; 1];
415 let mut g_u = [0.0; 1];
416 assert!(t.get_bounds_info(BoundsInfo {
417 x_l: &mut x_l,
418 x_u: &mut x_u,
419 g_l: &mut g_l,
420 g_u: &mut g_u
421 }));
422 assert_eq!(g_l[0], 1.0);
423
424 let mut grad = [0.0; 2];
425 assert!(t.eval_grad_f(&[3.0, 4.0], true, &mut grad));
426 assert_eq!(grad, [6.0, 8.0]);
427
428 // exact-Hessian default returns false
429 let mut tmp_v = [0.0; 0];
430 assert!(!t.eval_h(
431 None,
432 false,
433 1.0,
434 None,
435 false,
436 SparsityRequest::Values { values: &mut tmp_v }
437 ));
438
439 // Quasi-Newton info default
440 assert_eq!(t.get_number_of_nonlinear_variables(), -1);
441 }
442
443 #[test]
444 fn sparsity_request_round_trip() {
445 let mut t = Mini;
446 let mut irow = [0; 2];
447 let mut jcol = [0; 2];
448 assert!(t.eval_jac_g(
449 None,
450 false,
451 SparsityRequest::Structure {
452 irow: &mut irow,
453 jcol: &mut jcol
454 }
455 ));
456 assert_eq!(irow, [0, 0]);
457 assert_eq!(jcol, [0, 1]);
458
459 let mut vals = [0.0; 2];
460 assert!(t.eval_jac_g(
461 Some(&[1.0, 2.0]),
462 true,
463 SparsityRequest::Values { values: &mut vals }
464 ));
465 assert_eq!(vals, [1.0, 1.0]);
466 }
467}