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