pounce_cinterface/solver.rs
1//! Session-style C ABI built on [`pounce_sensitivity::Solver`].
2//!
3//! Adds an opaque [`IpoptSolver`] handle that captures the converged
4//! KKT factor between calls, so C consumers can issue many cheap
5//! operations (KKT back-solves, parametric steps, reduced Hessians)
6//! against the same factorization without re-running the IPM.
7//!
8//! ```c
9//! IpoptProblem prob = CreateIpoptProblem(...);
10//! AddIpoptStrOption(prob, "linear_solver", "feral");
11//! IpoptSolver sol = IpoptCreateSolver(&prob); // consumes prob
12//! IpoptSolverSolve(sol, x, NULL, NULL, NULL, NULL, NULL, user_data);
13//! IpoptSolverParametricStep(sol, 2, pin_indices, deltas, dx_out);
14//! IpoptSolverReducedHessian(sol, 2, pin_indices, 1.0, hr_out);
15//! IpoptFreeSolver(sol);
16//! ```
17//!
18//! Ownership: [`IpoptCreateSolver`] takes the IpoptProblem by **pointer
19//! to the handle** and nulls it out on success — the IpoptSolver
20//! becomes the sole owner. Calling [`crate::FreeIpoptProblem`] on the
21//! now-null handle is safe (it null-checks).
22
23use pounce_algorithm::application::{
24 IpoptApplication, default_backend_factory, feral_config_from_options, ma57_config_from_options,
25};
26use pounce_nlp::return_codes::ApplicationReturnStatus;
27use pounce_nlp::tnlp::TNLP;
28use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
29use pounce_restoration::resto_inner_solver::{
30 InnerBackendFactoryFactory, make_default_restoration_factory_provider,
31};
32use pounce_sensitivity::Solver as RustSolver;
33use std::cell::RefCell;
34use std::ffi::c_void;
35use std::rc::Rc;
36
37use crate::{
38 Bool, CCallbackTnlp, FALSE, Index, IpoptProblem, IpoptProblemInfo, LastSolve, Number, TRUE,
39};
40
41/// Internal owned state for the session-style C handle.
42pub struct IpoptSolverInfo {
43 /// The session. `None` before the first solve or after a solve
44 /// that didn't converge.
45 session: Option<RustSolver>,
46 /// All the problem state: callbacks, dims, bounds, options. On each
47 /// solve the inner `IpoptApplication` is moved into a fresh
48 /// `RustSolver` (held in `session`) and a blank app is left in its
49 /// place; `IpoptSolverSolve` clones the OptionsList across that move
50 /// so the user's options survive into the next solve.
51 problem: IpoptProblemInfo,
52 /// Number of constraints — cached for cheap shape checks.
53 m: Index,
54}
55
56/// Opaque session-style handle. Construction via
57/// [`IpoptCreateSolver`]; release via [`IpoptFreeSolver`].
58pub type IpoptSolver = *mut IpoptSolverInfo;
59
60/// Build an [`IpoptSolver`] session from a configured
61/// [`IpoptProblem`]. **Consumes the IpoptProblem** on success: the
62/// pointer at `*prob_handle` is set to NULL and ownership transfers
63/// to the returned IpoptSolver. The user should not use the original
64/// handle again, though calling [`crate::FreeIpoptProblem`] on the
65/// now-null pointer is harmless (it null-checks).
66///
67/// Returns NULL if `prob_handle` is NULL, `*prob_handle` is NULL, or
68/// the IpoptProblem hasn't been fully initialized.
69///
70/// # Safety
71///
72/// `prob_handle` must be a valid pointer to an [`IpoptProblem`]
73/// previously returned by [`crate::CreateIpoptProblem`] (or NULL).
74#[unsafe(no_mangle)]
75pub unsafe extern "C" fn IpoptCreateSolver(prob_handle: *mut IpoptProblem) -> IpoptSolver {
76 unsafe {
77 if prob_handle.is_null() {
78 return std::ptr::null_mut();
79 }
80 let prob = *prob_handle;
81 if prob.is_null() {
82 return std::ptr::null_mut();
83 }
84 // Take ownership of the Box and null out the caller's handle.
85 let problem = *Box::from_raw(prob);
86 *prob_handle = std::ptr::null_mut();
87 let m = problem.m;
88 let info = Box::new(IpoptSolverInfo {
89 session: None,
90 problem,
91 m,
92 });
93 Box::into_raw(info)
94 }
95}
96
97/// Release an [`IpoptSolver`] and all owned resources, including the
98/// IpoptProblem state that was consumed by [`IpoptCreateSolver`].
99///
100/// # Safety
101///
102/// `solver` must be a pointer returned by [`IpoptCreateSolver`] and
103/// not yet freed, or NULL.
104#[unsafe(no_mangle)]
105pub unsafe extern "C" fn IpoptFreeSolver(solver: IpoptSolver) {
106 unsafe {
107 if solver.is_null() {
108 return;
109 }
110 drop(Box::from_raw(solver));
111 }
112}
113
114/// Run the IPM. Same output buffer contract as [`crate::IpoptSolve`]:
115/// `x` is in/out (initial guess in, solution out); `g`, `obj_val`,
116/// `mult_g`, `mult_x_L`, `mult_x_U` are out-only and may be NULL.
117/// `user_data` is threaded into the C callbacks unchanged.
118///
119/// Returns the same `Index`-cast [`ApplicationReturnStatus`] code as
120/// [`crate::IpoptSolve`]. On a converged status the session retains
121/// the KKT factor for subsequent [`IpoptSolverKktSolve`],
122/// [`IpoptSolverParametricStep`], and [`IpoptSolverReducedHessian`]
123/// calls.
124///
125/// # Safety
126///
127/// All non-NULL output pointers must be valid for the appropriate
128/// length; the C callbacks stored on the underlying IpoptProblem must
129/// remain valid through the solve.
130#[unsafe(no_mangle)]
131#[allow(clippy::too_many_arguments)]
132pub unsafe extern "C" fn IpoptSolverSolve(
133 solver: IpoptSolver,
134 x: *mut Number,
135 g: *mut Number,
136 obj_val: *mut Number,
137 mult_g: *mut Number,
138 mult_x_L: *mut Number,
139 mult_x_U: *mut Number,
140 user_data: *mut c_void,
141) -> Index {
142 unsafe {
143 if solver.is_null() {
144 return ApplicationReturnStatus::InternalError as Index;
145 }
146 // Invalidate any prior session state up front, before this solve is
147 // attempted. The converged factor (`session`) and retained stats
148 // (`problem.last_solve`) are only repopulated when the solve below runs to
149 // completion; if the guarded body bails early or a panic is caught
150 // (returning `Internal_Error`), neither the held KKT factor nor the
151 // post-solve accessors must surface the *previous* solve's data. Clearing
152 // here makes the failure-consistent state "no data" rather than a stale
153 // factor / stale stats (F5).
154 {
155 let info = &mut *solver;
156 info.session = None;
157 info.problem.last_solve = None;
158 }
159 // Guard the whole solve: `RustSolver::solve` runs the entire pounce core
160 // and the C-callback bridge, any of which could panic on an unexpected
161 // internal state. A panic unwinding across `extern "C"` aborts the
162 // embedding process; report `Internal_Error` instead, matching
163 // `IpoptSolve` and upstream Ipopt's exception handling. (See `ffi_guard`.)
164 crate::ffi_guard(ApplicationReturnStatus::InternalError as Index, || {
165 let info = &mut *solver;
166 let n = info.problem.n;
167 let m = info.m;
168 if n < 0 || m < 0 {
169 return ApplicationReturnStatus::InvalidProblemDefinition as Index;
170 }
171 if n > 0 && x.is_null() {
172 return ApplicationReturnStatus::InvalidProblemDefinition as Index;
173 }
174 let n_us = n as usize;
175 let m_us = m as usize;
176 let initial_x = if n_us > 0 {
177 std::slice::from_raw_parts(x, n_us).to_vec()
178 } else {
179 Vec::new()
180 };
181
182 let bridge = Rc::new(RefCell::new(CCallbackTnlp {
183 n,
184 m,
185 nele_jac: info.problem.nele_jac,
186 nele_hess: info.problem.nele_hess,
187 index_style: info.problem.index_style,
188 x_l: info.problem.x_l.clone(),
189 x_u: info.problem.x_u.clone(),
190 g_l: info.problem.g_l.clone(),
191 g_u: info.problem.g_u.clone(),
192 initial_x,
193 eval_f: info.problem.eval_f,
194 eval_grad_f: info.problem.eval_grad_f,
195 eval_g: info.problem.eval_g,
196 eval_jac_g: info.problem.eval_jac_g,
197 eval_h: info.problem.eval_h,
198 user_data,
199 intermediate_cb: info.problem.intermediate_cb,
200 user_scaling: info.problem.user_scaling.clone(),
201 nonlinear_vars: info.problem.nonlinear_vars.clone(),
202 final_status: None,
203 final_x: vec![0.0; n_us],
204 final_z_l: vec![0.0; n_us],
205 final_z_u: vec![0.0; n_us],
206 final_g: vec![0.0; m_us],
207 final_lambda: vec![0.0; m_us],
208 final_obj: 0.0,
209 }));
210
211 // Re-wire restoration fresh for this solve (same pattern as
212 // IpoptSolve). Multi-pass provider so the ℓ₁ wrapper / auto-fallback
213 // don't panic on the second inner solve (pounce#10 / pounce#24).
214 let feral_cfg = feral_config_from_options(info.problem.app.options());
215 // The `ma57_*` options under the `"resto."` prefix — dead until
216 // gh#825, because nothing threaded any MA57 config into a factory.
217 let ma57_cfg = ma57_config_from_options(info.problem.app.options(), "resto.");
218 let bff_mint = move || -> InnerBackendFactoryFactory {
219 let feral_cfg = feral_cfg.clone();
220 let ma57_cfg = ma57_cfg.clone();
221 Box::new(move || default_backend_factory(feral_cfg.clone(), ma57_cfg.clone()))
222 };
223 let resto_provider = make_default_restoration_factory_provider(
224 RestoAlgorithmBuilder::new(),
225 info.problem.app.algorithm_builder_from_options(),
226 bff_mint,
227 );
228 info.problem
229 .app
230 .set_restoration_factory_provider(resto_provider);
231
232 // Move the app out of the problem and into a fresh RustSolver. The
233 // app carries the user's options (set via AddIpopt{Str,Num,Int}Option),
234 // so we snapshot the OptionsList first and restore it into the fresh
235 // blank app left behind. Without this, a second IpoptSolverSolve on the
236 // same handle reads a default-initialised app — silently discarding the
237 // linear solver, tolerances, scaling, etc. the caller configured (and
238 // the `feral_config_from_options` snapshot above would, on that second
239 // call, read the already-blanked options). The session API's design
240 // center is repeated solves, so this must survive across them.
241 let saved_options = info.problem.app.options().clone();
242 let app = std::mem::replace(&mut info.problem.app, IpoptApplication::new());
243 *info.problem.app.options_mut() = saved_options;
244 let bridge_for_solver: Rc<RefCell<dyn TNLP>> = bridge.clone();
245 let mut rust_solver = RustSolver::new(app, bridge_for_solver);
246 let status = rust_solver.solve();
247 let bridge_ref = bridge.borrow();
248 info.problem.last_solve = Some(LastSolve {
249 stats: rust_solver.app().statistics(),
250 status,
251 linear_solver: rust_solver.app().linear_solver_summary(),
252 final_x: bridge_ref.final_x.clone(),
253 final_lambda: bridge_ref.final_lambda.clone(),
254 final_obj: bridge_ref.final_obj,
255 });
256 if !x.is_null() && n_us > 0 {
257 std::ptr::copy_nonoverlapping(bridge_ref.final_x.as_ptr(), x, n_us);
258 }
259 if !g.is_null() && m_us > 0 {
260 std::ptr::copy_nonoverlapping(bridge_ref.final_g.as_ptr(), g, m_us);
261 }
262 if !obj_val.is_null() {
263 *obj_val = bridge_ref.final_obj;
264 }
265 if !mult_g.is_null() && m_us > 0 {
266 std::ptr::copy_nonoverlapping(bridge_ref.final_lambda.as_ptr(), mult_g, m_us);
267 }
268 if !mult_x_L.is_null() && n_us > 0 {
269 std::ptr::copy_nonoverlapping(bridge_ref.final_z_l.as_ptr(), mult_x_L, n_us);
270 }
271 if !mult_x_U.is_null() && n_us > 0 {
272 std::ptr::copy_nonoverlapping(bridge_ref.final_z_u.as_ptr(), mult_x_U, n_us);
273 }
274
275 info.session = Some(rust_solver);
276 status as Index
277 })
278 }
279}
280
281/// Total compound-KKT vector dimension. Returns -1 if no converged
282/// factor is held.
283///
284/// # Safety
285///
286/// `solver` must be a valid [`IpoptSolver`] or NULL.
287#[unsafe(no_mangle)]
288pub unsafe extern "C" fn IpoptSolverGetKktDim(solver: IpoptSolver) -> Index {
289 unsafe {
290 if solver.is_null() {
291 return -1;
292 }
293 let info = &*solver;
294 match info.session.as_ref().and_then(|s| s.kkt_dim()) {
295 Some(d) => d as Index,
296 None => -1,
297 }
298 }
299}
300
301/// Solve `K · lhs = rhs` against the converged KKT factor. Both
302/// `rhs` and `lhs` are flat buffers of length [`IpoptSolverGetKktDim`]
303/// in the `x || s || y_c || y_d || z_l || z_u || v_l || v_u` packing.
304///
305/// `K` is the **natural-units** (unscaled) KKT matrix: any NLP
306/// scaling the IPM applied (`nlp_scaling_method`) is undone in the
307/// back-solve, so RHS and solution are in the user's own units
308/// (pounce#128). Use [`IpoptSolverKktSolveScaled`] for the raw
309/// back-solve against the factor exactly as the IPM holds it (the
310/// pre-#128 behavior).
311///
312/// Returns `TRUE` on success, `FALSE` if no factor is held or the
313/// back-solve fails.
314///
315/// # Safety
316///
317/// `rhs` and `lhs` must point to buffers at least
318/// [`IpoptSolverGetKktDim`] doubles long.
319#[unsafe(no_mangle)]
320pub unsafe extern "C" fn IpoptSolverKktSolve(
321 solver: IpoptSolver,
322 rhs: *const Number,
323 lhs: *mut Number,
324) -> Bool {
325 unsafe { kkt_solve_impl(solver, rhs, lhs, false) }
326}
327
328/// [`IpoptSolverKktSolve`] without the natural-units correction: the
329/// back-solve runs in the solver's internal scaled space. Identical
330/// to `IpoptSolverKktSolve` when no NLP scaling is active.
331///
332/// # Safety
333///
334/// Same contract as [`IpoptSolverKktSolve`].
335#[unsafe(no_mangle)]
336pub unsafe extern "C" fn IpoptSolverKktSolveScaled(
337 solver: IpoptSolver,
338 rhs: *const Number,
339 lhs: *mut Number,
340) -> Bool {
341 unsafe { kkt_solve_impl(solver, rhs, lhs, true) }
342}
343
344unsafe fn kkt_solve_impl(
345 solver: IpoptSolver,
346 rhs: *const Number,
347 lhs: *mut Number,
348 scaled: bool,
349) -> Bool {
350 // Guard the back-solve: it runs the linear-solver kernel against the
351 // retained factor, which could panic on an unexpected state. A panic
352 // unwinding across the `extern "C"` callers (`IpoptSolverKktSolve` /
353 // `IpoptSolverKktSolveScaled`) aborts the embedding process; report
354 // `FALSE` instead. (See `ffi_guard`.)
355 crate::ffi_guard(FALSE, || unsafe {
356 if solver.is_null() || rhs.is_null() || lhs.is_null() {
357 return FALSE;
358 }
359 let info = &*solver;
360 let Some(s) = info.session.as_ref() else {
361 return FALSE;
362 };
363 let Some(dim) = s.kkt_dim() else {
364 return FALSE;
365 };
366 let rhs_slice = std::slice::from_raw_parts(rhs, dim);
367 let mut lhs_vec = vec![0.0; dim];
368 let res = if scaled {
369 s.kkt_solve_scaled(rhs_slice, &mut lhs_vec)
370 } else {
371 s.kkt_solve(rhs_slice, &mut lhs_vec)
372 };
373 if res.is_err() {
374 return FALSE;
375 }
376 std::ptr::copy_nonoverlapping(lhs_vec.as_ptr(), lhs, dim);
377 TRUE
378 })
379}
380
381/// Like [`std::slice::from_raw_parts`], but yields an empty slice when
382/// `len == 0` instead of dereferencing `ptr`. A legal zero-length call
383/// (`n_pins == 0`) is allowed to pass a NULL/dangling pointer, yet
384/// `from_raw_parts` requires its pointer be non-null and aligned *even
385/// for empty slices* — `from_raw_parts(NULL, 0)` is undefined behaviour
386/// and trips the `slice::from_raw_parts requires the pointer to be
387/// aligned and non-null` debug-assertion on recent Rust. This mirrors
388/// the `n_us > 0` gate already used in `IpoptSolverSolve`.
389///
390/// # Safety
391///
392/// When `len > 0`, `ptr` must point to `len` valid, initialized `T`.
393unsafe fn slice_or_empty<'a, T>(ptr: *const T, len: usize) -> &'a [T] {
394 unsafe {
395 if len == 0 {
396 &[]
397 } else {
398 std::slice::from_raw_parts(ptr, len)
399 }
400 }
401}
402
403/// First-order parametric step `Δx ≈ ∂x*/∂p · Δp`. `pin_indices` is
404/// `n_pins` `Index` values (0-based indices into `g(x)`); `deltas` is
405/// the parameter perturbation `Δp` of the same length; `dx_out` is the
406/// `n`-long primal step output (length matches the problem's `n`).
407///
408/// Returns `TRUE` on success, `FALSE` if no converged factor, invalid
409/// indices, or the sensitivity computation fails.
410///
411/// # Safety
412///
413/// `pin_indices` and `deltas` must point to `n_pins` valid elements;
414/// `dx_out` must point to at least `n` `Number` slots (`n` from the
415/// underlying IpoptProblem).
416#[unsafe(no_mangle)]
417pub unsafe extern "C" fn IpoptSolverParametricStep(
418 solver: IpoptSolver,
419 n_pins: Index,
420 pin_indices: *const Index,
421 deltas: *const Number,
422 dx_out: *mut Number,
423) -> Bool {
424 // Guard the sensitivity solve: it runs the linear-solver kernel against
425 // the retained factor, which could panic on an unexpected state. A panic
426 // unwinding across `extern "C"` aborts the embedding process; report
427 // `FALSE` instead. (See `ffi_guard`.)
428 crate::ffi_guard(FALSE, || unsafe {
429 if solver.is_null() || n_pins < 0 {
430 return FALSE;
431 }
432 if n_pins > 0 && (pin_indices.is_null() || deltas.is_null()) {
433 return FALSE;
434 }
435 if dx_out.is_null() {
436 return FALSE;
437 }
438 let info = &*solver;
439 let Some(s) = info.session.as_ref() else {
440 return FALSE;
441 };
442 let m = info.m;
443 let pins_raw = slice_or_empty(pin_indices, n_pins as usize);
444 let mut pins = Vec::with_capacity(n_pins as usize);
445 for &i in pins_raw {
446 if i < 0 || i >= m {
447 return FALSE;
448 }
449 pins.push(i as pounce_common::types::Index);
450 }
451 let deltas_slice = slice_or_empty(deltas, n_pins as usize);
452 let Ok(dx) = s.parametric_step(&pins, deltas_slice) else {
453 return FALSE;
454 };
455 std::ptr::copy_nonoverlapping(dx.as_ptr(), dx_out, dx.len());
456 TRUE
457 })
458}
459
460/// Reduced Hessian `obj_scal · B K⁻¹ Bᵀ` over the pinned rows.
461/// `hr_out` receives an `n_pins²`-long column-major dense matrix.
462///
463/// The value is in **natural (unscaled) units**: any NLP scaling the
464/// IPM applied (`nlp_scaling_method`) is undone before it is reported,
465/// so `-inv(...)` of it is directly the parameter covariance of an
466/// estimation problem (pounce#128). `obj_scal` is a plain extra
467/// multiplier (pass 1.0); it is no longer needed to undo pounce's own
468/// scaling.
469///
470/// **Sign convention: this writes `−H_R`, not `H_R`** (gh#937) — see
471/// [`pounce_sensitivity::Solver::compute_reduced_hessian`], which it
472/// forwards to. Negate `hr_out` to read curvature.
473///
474/// Returns `TRUE` on success, `FALSE` otherwise.
475///
476/// # Safety
477///
478/// `pin_indices` must point to `n_pins` valid elements; `hr_out` must
479/// point to at least `n_pins²` `Number` slots.
480#[unsafe(no_mangle)]
481pub unsafe extern "C" fn IpoptSolverReducedHessian(
482 solver: IpoptSolver,
483 n_pins: Index,
484 pin_indices: *const Index,
485 obj_scal: Number,
486 hr_out: *mut Number,
487) -> Bool {
488 // Guard the reduced-Hessian assembly: it runs repeated back-solves against
489 // the retained factor, which could panic on an unexpected state. A panic
490 // unwinding across `extern "C"` aborts the embedding process; report
491 // `FALSE` instead. (See `ffi_guard`.)
492 crate::ffi_guard(FALSE, || unsafe {
493 if solver.is_null() || n_pins < 0 || hr_out.is_null() {
494 return FALSE;
495 }
496 if n_pins > 0 && pin_indices.is_null() {
497 return FALSE;
498 }
499 let info = &*solver;
500 let Some(s) = info.session.as_ref() else {
501 return FALSE;
502 };
503 let m = info.m;
504 let pins_raw = slice_or_empty(pin_indices, n_pins as usize);
505 let mut pins = Vec::with_capacity(n_pins as usize);
506 for &i in pins_raw {
507 if i < 0 || i >= m {
508 return FALSE;
509 }
510 pins.push(i as pounce_common::types::Index);
511 }
512 let Ok(hr) = s.compute_reduced_hessian(&pins, obj_scal) else {
513 return FALSE;
514 };
515 std::ptr::copy_nonoverlapping(hr.as_ptr(), hr_out, hr.len());
516 TRUE
517 })
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::{AddIpoptIntOption, CreateIpoptProblem, FreeIpoptProblem};
524 use std::ffi::CString;
525
526 // f(x) = (x - 2)^2 — the same 1-D quadratic the bridge tests use;
527 // converges in one Newton step.
528 unsafe extern "C" fn quad_eval_f(
529 _n: Index,
530 x: *const Number,
531 _new_x: Bool,
532 obj_value: *mut Number,
533 _user_data: *mut c_void,
534 ) -> Bool {
535 unsafe {
536 let v = *x.offset(0);
537 *obj_value = (v - 2.0) * (v - 2.0);
538 TRUE
539 }
540 }
541 unsafe extern "C" fn quad_eval_grad_f(
542 _n: Index,
543 x: *const Number,
544 _new_x: Bool,
545 grad: *mut Number,
546 _user_data: *mut c_void,
547 ) -> Bool {
548 unsafe {
549 let v = *x.offset(0);
550 *grad.offset(0) = 2.0 * (v - 2.0);
551 TRUE
552 }
553 }
554 unsafe extern "C" fn quad_eval_h(
555 _n: Index,
556 _x: *const Number,
557 _new_x: Bool,
558 obj_factor: Number,
559 _m: Index,
560 _lambda: *const Number,
561 _new_lambda: Bool,
562 _nele_hess: Index,
563 irow: *mut Index,
564 jcol: *mut Index,
565 values: *mut Number,
566 _user_data: *mut c_void,
567 ) -> Bool {
568 unsafe {
569 if !irow.is_null() && !jcol.is_null() && values.is_null() {
570 *irow.offset(0) = 0;
571 *jcol.offset(0) = 0;
572 } else if irow.is_null() && jcol.is_null() && !values.is_null() {
573 *values.offset(0) = 2.0 * obj_factor;
574 } else {
575 return FALSE;
576 }
577 TRUE
578 }
579 }
580
581 fn create_quad() -> IpoptProblem {
582 let xl = [-1.0e20];
583 let xu = [1.0e20];
584 unsafe {
585 CreateIpoptProblem(
586 1,
587 xl.as_ptr(),
588 xu.as_ptr(),
589 0,
590 std::ptr::null(),
591 std::ptr::null(),
592 0,
593 1,
594 0,
595 Some(quad_eval_f),
596 None,
597 Some(quad_eval_grad_f),
598 None,
599 Some(quad_eval_h),
600 )
601 }
602 }
603
604 /// H13: a user option set before `IpoptCreateSolver` must survive every
605 /// `IpoptSolverSolve` on the handle. Before the fix the app (and its
606 /// OptionsList) was `mem::replace`d with a blank default on the first
607 /// solve and never restored, so the second solve silently ran with
608 /// default options. Here we set a clearly non-default `max_iter = 7`
609 /// and assert it is still present after the first AND second solve.
610 #[test]
611 fn options_survive_repeated_session_solves() {
612 let mut prob = create_quad();
613 let key = CString::new("max_iter").unwrap();
614 assert_eq!(unsafe { AddIpoptIntOption(prob, key.as_ptr(), 7) }, TRUE);
615
616 // IpoptCreateSolver consumes the problem and nulls the handle.
617 let solver = unsafe { IpoptCreateSolver(&mut prob) };
618 assert!(!solver.is_null());
619 assert!(prob.is_null(), "create must null the caller's handle");
620
621 let read_max_iter = |solver: IpoptSolver| -> Option<i32> {
622 let info = unsafe { &*solver };
623 match info.problem.app.options().get_integer_value("max_iter", "") {
624 Ok((v, true)) => Some(v),
625 _ => None,
626 }
627 };
628
629 // The option is present before any solve.
630 assert_eq!(read_max_iter(solver), Some(7), "option set pre-solve");
631
632 let mut x = [0.0_f64];
633 let mut obj = 0.0_f64;
634 let solve = |solver: IpoptSolver, x: &mut [f64], obj: &mut f64| unsafe {
635 IpoptSolverSolve(
636 solver,
637 x.as_mut_ptr(),
638 std::ptr::null_mut(),
639 obj as *mut f64,
640 std::ptr::null_mut(),
641 std::ptr::null_mut(),
642 std::ptr::null_mut(),
643 std::ptr::null_mut(),
644 )
645 };
646
647 // First solve — the app is moved into the session; the OptionsList
648 // must be restored into the blank app left behind.
649 let _ = solve(solver, &mut x, &mut obj);
650 assert_eq!(
651 read_max_iter(solver),
652 Some(7),
653 "max_iter must survive the first session solve (H13)"
654 );
655
656 // Second solve — the design center of the session API. Pre-fix this
657 // ran on a blanked app; the option must still be there.
658 let _ = solve(solver, &mut x, &mut obj);
659 assert_eq!(
660 read_max_iter(solver),
661 Some(7),
662 "max_iter must survive a second session solve (H13)"
663 );
664
665 unsafe { IpoptFreeSolver(solver) };
666 // The (now-null) problem handle is safe to free.
667 unsafe { FreeIpoptProblem(prob) };
668 }
669
670 /// M37: a legal `n_pins == 0` call to the sensitivity entry points is
671 /// allowed to pass NULL `pin_indices`/`deltas` (there is nothing to
672 /// point at), but the implementation fed those straight into
673 /// `slice::from_raw_parts(NULL, 0)` — undefined behaviour that aborts
674 /// the process under the `-C debug-assertions` precondition checks
675 /// recent rustc emits. The session check sits *before* the bad
676 /// `from_raw_parts`, so a converged solver is required to reach it.
677 /// Pre-fix this test aborts the binary; post-fix the calls return a
678 /// well-defined `Bool` (an empty pin set is a no-op back-solve).
679 #[test]
680 fn zero_pins_with_null_pointers_is_not_ub() {
681 let mut prob = create_quad();
682 let solver = unsafe { IpoptCreateSolver(&mut prob) };
683 assert!(!solver.is_null());
684
685 // Solve so the handle holds a converged session (the null-pointer
686 // path past the session guard is what trips the UB).
687 let mut x = [0.0_f64];
688 let mut obj = 0.0_f64;
689 let status = unsafe {
690 IpoptSolverSolve(
691 solver,
692 x.as_mut_ptr(),
693 std::ptr::null_mut(),
694 &mut obj as *mut f64,
695 std::ptr::null_mut(),
696 std::ptr::null_mut(),
697 std::ptr::null_mut(),
698 std::ptr::null_mut(),
699 )
700 };
701 assert_eq!(status, ApplicationReturnStatus::SolveSucceeded as Index);
702
703 // n_pins == 0 with NULL pin/delta pointers — the legal empty call.
704 // dx_out is a real n-long buffer (n == 1 here); n_pins² == 0 so the
705 // reduced-Hessian output buffer is never written, but pass a valid
706 // pointer anyway.
707 let mut dx_out = [0.0_f64];
708 let mut hr_out = [0.0_f64];
709
710 // Reaching the assertions at all means no `from_raw_parts(NULL, 0)`
711 // abort fired. An empty pin set is a well-defined no-op: a zero
712 // perturbation yields Δx ≈ 0 and an empty (0×0) reduced Hessian, so
713 // both calls succeed with TRUE — the defined, non-UB outcome.
714 let step = unsafe {
715 IpoptSolverParametricStep(
716 solver,
717 0,
718 std::ptr::null(),
719 std::ptr::null(),
720 dx_out.as_mut_ptr(),
721 )
722 };
723 assert_eq!(step, TRUE, "empty parametric step is a defined no-op");
724
725 let rh = unsafe {
726 IpoptSolverReducedHessian(solver, 0, std::ptr::null(), 1.0, hr_out.as_mut_ptr())
727 };
728 assert_eq!(rh, TRUE, "empty reduced Hessian is a defined no-op");
729
730 unsafe { IpoptFreeSolver(solver) };
731 unsafe { FreeIpoptProblem(prob) };
732 }
733
734 /// F5 (session arm): `IpoptSolverSolve` is now wrapped in `ffi_guard`, so
735 /// a pounce-internal panic is converted to `Internal_Error` instead of
736 /// aborting the embedding process. The secondary half of F5 is the state
737 /// hygiene that wrapping demands: the call must invalidate the retained
738 /// session factor (`session`) and stats (`problem.last_solve`) **up
739 /// front**, so a solve that bails — or whose panic `ffi_guard` catches —
740 /// does not leave the handle holding the *previous* solve's converged
741 /// factorization (against which a later `IpoptSolverKktSolve` would
742 /// silently back-solve) or stale stats.
743 ///
744 /// A caught panic can't be injected deterministically through the public
745 /// C ABI (a panic in a user `extern "C"` callback aborts at its own
746 /// boundary, before unwinding reaches `ffi_guard`; see that fn's note).
747 /// So we drive the equivalent control-flow shape: after a successful
748 /// solve we corrupt the cached constraint count to a negative value, so
749 /// the next `IpoptSolverSolve` returns `InvalidProblemDefinition` from
750 /// inside the guarded body **without** reaching the trailing
751 /// `session = Some(..)` / `last_solve = Some(..)` writes — exactly where a
752 /// caught panic also bails. The up-front clear is what makes the
753 /// post-failure state "no data" in both cases.
754 #[test]
755 fn stale_session_state_cleared_when_resolve_bails() {
756 let mut prob = create_quad();
757 let solver = unsafe { IpoptCreateSolver(&mut prob) };
758 assert!(!solver.is_null());
759
760 let mut x = [0.0_f64];
761 let mut obj = 0.0_f64;
762 let solve = |solver: IpoptSolver, x: &mut [f64], obj: &mut f64| unsafe {
763 IpoptSolverSolve(
764 solver,
765 x.as_mut_ptr(),
766 std::ptr::null_mut(),
767 obj as *mut f64,
768 std::ptr::null_mut(),
769 std::ptr::null_mut(),
770 std::ptr::null_mut(),
771 std::ptr::null_mut(),
772 )
773 };
774
775 // A converged solve holds a factor and records stats.
776 let rc = solve(solver, &mut x, &mut obj);
777 assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
778 {
779 let info = unsafe { &*solver };
780 assert!(
781 info.session.is_some(),
782 "converged solve should hold a session factor"
783 );
784 assert!(
785 info.problem.last_solve.is_some(),
786 "converged solve should record stats"
787 );
788 }
789 assert!(
790 unsafe { IpoptSolverGetKktDim(solver) } >= 0,
791 "a held factor reports a non-negative KKT dim"
792 );
793
794 // Corrupt the cached constraint count so the next solve bails early in
795 // the guarded body (the InvalidProblemDefinition guard) — the same
796 // place a caught panic would land — without recording anything.
797 unsafe { (*solver).m = -1 };
798 let mut x2 = [0.0_f64];
799 let mut obj2 = 0.0_f64;
800 let rc2 = solve(solver, &mut x2, &mut obj2);
801 assert_eq!(
802 rc2,
803 ApplicationReturnStatus::InvalidProblemDefinition as Index
804 );
805
806 // Post-fix: the up-front invalidation dropped the stale factor and
807 // stats. Pre-fix both survived — a subsequent KKT back-solve would run
808 // silently against the abandoned factorization.
809 {
810 let info = unsafe { &*solver };
811 assert!(
812 info.session.is_none(),
813 "bailed solve must drop the stale session factor (F5)"
814 );
815 assert!(
816 info.problem.last_solve.is_none(),
817 "bailed solve must clear stale stats (F5)"
818 );
819 }
820 assert_eq!(
821 unsafe { IpoptSolverGetKktDim(solver) },
822 -1,
823 "no factor is held after a bailed re-solve (F5)"
824 );
825
826 unsafe { IpoptFreeSolver(solver) };
827 unsafe { FreeIpoptProblem(prob) };
828 }
829}