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