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