Skip to main content

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