Skip to main content

pounce_cinterface/
lib.rs

1//! POUNCE C ABI — port of `Interfaces/IpStdCInterface.{h,cpp}`.
2//!
3//! Provides the `CreateIpoptProblem / IpoptSolve / FreeIpoptProblem` C
4//! entry points that existing PyIpopt / cyipopt / JuMP wrappers link
5//! against. Function names and signatures match upstream Ipopt 3.14.x
6//! exactly so consumers can swap `libipopt.{dylib,so}` for
7//! `libpounce_cinterface` without rebuilding.
8//!
9//! Surface area (in `IpStdCInterface.h` order):
10//!
11//! * Lifecycle: [`CreateIpoptProblem`], [`FreeIpoptProblem`].
12//! * Options: [`AddIpoptStrOption`], [`AddIpoptNumOption`],
13//!   [`AddIpoptIntOption`], [`OpenIpoptOutputFile`],
14//!   [`SetIpoptProblemScaling`].
15//! * Callbacks: [`SetIntermediateCallback`].
16//! * Solve: [`IpoptSolve`].
17//! * Introspection (only valid inside an intermediate callback):
18//!   [`GetIpoptCurrentIterate`], [`GetIpoptCurrentViolations`].
19//! * Library info: [`GetIpoptVersion`].
20//!
21//! Pounce extensions for post-solve stats (not present in upstream
22//! Ipopt's C API): [`GetIpoptIterCount`], [`GetIpoptSolveTime`],
23//! [`GetIpoptPrimalInf`], [`GetIpoptDualInf`], [`GetIpoptComplInf`].
24//!
25//! All entry points are `extern "C"` and `#[no_mangle]`. Pointers are
26//! raw and the caller is responsible for lifetime; the `IpoptProblem`
27//! handle is opaque (`*mut c_void` from C's perspective). The Fortran
28//! 77 ABI shim lives in [`fortran`].
29
30#![allow(non_camel_case_types, non_snake_case)]
31#![allow(unsafe_op_in_unsafe_fn, dead_code)]
32#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
33
34pub mod fortran;
35pub mod solver;
36
37use pounce_algorithm::application::{
38    IpoptApplication, default_backend_factory, feral_config_from_options, ma57_config_from_options,
39};
40use pounce_algorithm::intermediate as ip_intermediate;
41use pounce_common::reg_options::OptionType;
42use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
43use pounce_nlp::return_codes::ApplicationReturnStatus;
44use pounce_nlp::solve_statistics::SolveStatistics;
45use pounce_nlp::tnlp::{
46    BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution, SparsityRequest,
47    StartingPoint, TNLP,
48};
49use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
50use pounce_restoration::resto_inner_solver::{
51    InnerBackendFactoryFactory, make_default_restoration_factory_provider,
52};
53use pounce_restoration::second_opinion_driver::run_second_opinion_ladder;
54use std::cell::RefCell;
55use std::ffi::{CStr, c_char, c_int, c_void};
56use std::rc::Rc;
57
58/// Mirrors C `Number` typedef in `IpStdCInterface.h`.
59pub type Number = f64;
60/// Mirrors C `Index`.
61pub type Index = c_int;
62/// Mirrors C `Bool`, which `pounce.h` — like Ipopt 3.14's
63/// `IpStdCInterface.h` — declares as the C99 `bool`. That is **one byte**,
64/// so this is `u8` and not `c_int`.
65///
66/// It was `c_int` until gh#624, i.e. four bytes on the Rust side against
67/// one on every C caller's, in both directions:
68///
69/// * a callback returning `false` sets only `AL`, and the x86-64 psABI
70///   leaves the rest of `EAX` unspecified — read as an `i32`, a failed
71///   evaluation could come back nonzero, which reads as *success*. The
72///   solver would then accept a point it was told it could not evaluate
73///   instead of cutting the step. gcc and clang emit `movzbl`, which is
74///   why this stayed latent rather than exploding;
75/// * an *array* of them would have been a hard stride bug, 1-byte
76///   elements read at 4-byte spacing. That is why
77///   [`IpoptSetNonlinearVariables`] takes a count plus an index list
78///   rather than the `Bool` mask gh#624 originally proposed.
79///
80/// `u8` rather than Rust's `bool` on purpose: the two have identical
81/// layout, but `bool` carries a validity invariant (it *must* hold 0 or
82/// 1), and a C caller reaching this boundary with anything else — an
83/// older header where `Bool` was `int`, a hand-rolled binding, a value
84/// that came through a `memcpy` — would be instant undefined behaviour.
85/// `u8` accepts whatever arrives and tests it the way C does, which is
86/// the same reason the entry points validate rather than trust.
87pub type Bool = u8;
88
89const TRUE: Bool = 1;
90const FALSE: Bool = 0;
91
92// The whole point of the alias. `pounce.h` says `typedef bool Bool`, so a
93// build where this stops being one byte is one where every C caller
94// disagrees with the implementation about every boolean.
95const _: () = assert!(
96    core::mem::size_of::<Bool>() == 1,
97    "Bool must be one byte to match `typedef bool Bool` in pounce.h"
98);
99
100/// Run an FFI entry-point body, converting any Rust panic into `fallback`
101/// rather than letting it unwind across the `extern "C"` boundary — which is
102/// undefined behavior and, in practice, a process abort that takes the
103/// embedding application down with it. Upstream Ipopt's C interface likewise
104/// wraps the solve in `try { … } catch(…)` and reports `Internal_Error`
105/// instead of propagating a C++ exception across the ABI.
106///
107/// Note: this guards panics that originate in *pounce's own* Rust code (the
108/// solver core, the callback bridge, numerical kernels). A panic inside a
109/// user-supplied `extern "C"` callback aborts at that callback's own ABI
110/// boundary, before unwinding can reach here — that is the caller's
111/// responsibility, exactly as in the C/C++ original.
112pub(crate) fn ffi_guard<R>(fallback: R, body: impl FnOnce() -> R) -> R {
113    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
114        Ok(r) => r,
115        Err(_) => fallback,
116    }
117}
118
119/// C-ABI encoding of [`pounce_qp::BoundStatus`] (§7.2 of the
120/// active-set-SQP design note). Stable values:
121/// `0 = Inactive`, `1 = AtLower`, `2 = AtUpper`, `3 = Fixed`.
122pub type IpoptBoundStatus = c_int;
123/// C-ABI encoding of [`pounce_qp::ConsStatus`] (§7.2). Stable values:
124/// `0 = Inactive`, `1 = AtLower`, `2 = AtUpper`, `3 = Equality`.
125pub type IpoptConsStatus = c_int;
126
127const POUNCE_WS_INACTIVE: c_int = 0;
128const POUNCE_WS_AT_LOWER: c_int = 1;
129const POUNCE_WS_AT_UPPER: c_int = 2;
130const POUNCE_WS_FIXED_OR_EQ: c_int = 3;
131
132/// Internal owned state behind the opaque `IpoptProblem` handle.
133/// `#[repr(C)]` is unnecessary because C only sees the pointer.
134pub struct IpoptProblemInfo {
135    pub(crate) app: IpoptApplication,
136    pub(crate) n: Index,
137    pub(crate) m: Index,
138    pub(crate) nele_jac: Index,
139    pub(crate) nele_hess: Index,
140    pub(crate) index_style: Index,
141    pub(crate) x_l: Vec<Number>,
142    pub(crate) x_u: Vec<Number>,
143    pub(crate) g_l: Vec<Number>,
144    pub(crate) g_u: Vec<Number>,
145    pub(crate) eval_f: Option<Eval_F_CB>,
146    pub(crate) eval_g: Option<Eval_G_CB>,
147    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
148    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
149    pub(crate) eval_h: Option<Eval_H_CB>,
150    pub(crate) intermediate_cb: Option<Intermediate_CB>,
151    /// User-provided scaling installed by [`SetIpoptProblemScaling`].
152    /// `obj_scaling` defaults to `1.0`. `x_scaling`/`g_scaling` are
153    /// `None` when the user passed NULL.
154    pub(crate) user_scaling: Option<UserScaling>,
155    /// Final iterate and stats from the most recent [`IpoptSolve`].
156    /// Used by `GetIpopt{IterCount,SolveTime,...}` accessors. Reset
157    /// (cleared) by the next `IpoptSolve` call.
158    pub(crate) last_solve: Option<LastSolve>,
159    /// Working set staged by [`IpoptSetWarmStartWorkingSet`], pending
160    /// until the next [`IpoptSolve`].
161    ///
162    /// Only the working set is stored here — deliberately *not* a full
163    /// `SqpIterates`. The primal/dual iterate is not knowable at set
164    /// time; it arrives with the `x` (and, under
165    /// `warm_start_init_point=yes`, `mult_g`/`mult_x_L`/`mult_x_U`)
166    /// buffers passed to `IpoptSolve`. Building the `SqpIterates`
167    /// eagerly here forced the primal to a placeholder — zeros — which
168    /// then *became* the starting iterate, because
169    /// `SqpAlgorithm::optimize_with_warm_start` uses a supplied warm
170    /// iterate instead of querying the NLP for a starting point. The
171    /// merge therefore has to happen inside `IpoptSolve` (gh#484).
172    pub(crate) pending_working_set: Option<pounce_qp::WorkingSet>,
173    /// Nonlinear-variable subset staged by
174    /// [`IpoptSetNonlinearVariables`] (gh#624). Stored in the problem's
175    /// own index style, exactly as the caller passed it; the bridge
176    /// TNLP serves it back through
177    /// `get_number_of_nonlinear_variables` /
178    /// `get_list_of_nonlinear_variables`, which is where the algorithm
179    /// reads it. `None` — the default — means "every variable is
180    /// nonlinear".
181    pub(crate) nonlinear_vars: Option<Vec<Index>>,
182}
183
184/// User-provided NLP scaling stored on the problem until
185/// [`IpoptSolve`] copies it into the [`CCallbackTnlp`] bridge.
186#[derive(Clone)]
187pub(crate) struct UserScaling {
188    obj_scaling: Number,
189    x_scaling: Option<Vec<Number>>,
190    g_scaling: Option<Vec<Number>>,
191}
192
193/// Stats and final-iterate snapshot retained between
194/// [`IpoptSolve`] and the post-solve accessors. Everything needed to
195/// reconstruct a `pounce.solve-report/v1` JSON file lives here so
196/// [`IpoptWriteSolveReport`] doesn't have to ask the caller to thread
197/// `x`/`lambda`/`obj` back in.
198#[derive(Clone)]
199pub(crate) struct LastSolve {
200    pub(crate) stats: SolveStatistics,
201    pub(crate) status: ApplicationReturnStatus,
202    pub(crate) linear_solver: Option<pounce_linsol::summary::LinearSolverSummary>,
203    pub(crate) final_x: Vec<Number>,
204    pub(crate) final_lambda: Vec<Number>,
205    pub(crate) final_obj: Number,
206}
207
208impl Default for LastSolve {
209    fn default() -> Self {
210        Self {
211            stats: SolveStatistics::default(),
212            status: ApplicationReturnStatus::InternalError,
213            linear_solver: None,
214            final_x: Vec::new(),
215            final_lambda: Vec::new(),
216            final_obj: 0.0,
217        }
218    }
219}
220
221pub type IpoptProblem = *mut IpoptProblemInfo;
222
223// User-callback function pointer types — match
224// `IpStdCInterface.h:Eval_F_CB` etc. byte for byte.
225
226pub type Eval_F_CB = unsafe extern "C" fn(
227    n: Index,
228    x: *const Number,
229    new_x: Bool,
230    obj_value: *mut Number,
231    user_data: *mut c_void,
232) -> Bool;
233
234pub type Eval_Grad_F_CB = unsafe extern "C" fn(
235    n: Index,
236    x: *const Number,
237    new_x: Bool,
238    grad_f: *mut Number,
239    user_data: *mut c_void,
240) -> Bool;
241
242pub type Eval_G_CB = unsafe extern "C" fn(
243    n: Index,
244    x: *const Number,
245    new_x: Bool,
246    m: Index,
247    g: *mut Number,
248    user_data: *mut c_void,
249) -> Bool;
250
251pub type Eval_Jac_G_CB = unsafe extern "C" fn(
252    n: Index,
253    x: *const Number,
254    new_x: Bool,
255    m: Index,
256    nele_jac: Index,
257    iRow: *mut Index,
258    jCol: *mut Index,
259    values: *mut Number,
260    user_data: *mut c_void,
261) -> Bool;
262
263pub type Eval_H_CB = unsafe extern "C" fn(
264    n: Index,
265    x: *const Number,
266    new_x: Bool,
267    obj_factor: Number,
268    m: Index,
269    lambda: *const Number,
270    new_lambda: Bool,
271    nele_hess: Index,
272    iRow: *mut Index,
273    jCol: *mut Index,
274    values: *mut Number,
275    user_data: *mut c_void,
276) -> Bool;
277
278pub type Intermediate_CB = unsafe extern "C" fn(
279    alg_mod: Index,
280    iter_count: Index,
281    obj_value: Number,
282    inf_pr: Number,
283    inf_du: Number,
284    mu: Number,
285    d_norm: Number,
286    regularization_size: Number,
287    alpha_du: Number,
288    alpha_pr: Number,
289    ls_trials: Index,
290    user_data: *mut c_void,
291) -> Bool;
292
293/// Port of `IpStdCInterface.cpp:CreateIpoptProblem`. Returns NULL on
294/// invalid arguments (negative n/m, missing required callbacks, NULL
295/// bound pointers when the corresponding dimension is positive).
296///
297/// # Safety
298///
299/// `x_L`, `x_U` must be valid pointers to `n` `Number`s when `n > 0`.
300/// `g_L`, `g_U` must be valid pointers to `m` `Number`s when `m > 0`.
301/// The callback function pointers must be valid for the lifetime of
302/// the returned [`IpoptProblem`].
303#[unsafe(no_mangle)]
304pub unsafe extern "C" fn CreateIpoptProblem(
305    n: Index,
306    x_L: *const Number,
307    x_U: *const Number,
308    m: Index,
309    g_L: *const Number,
310    g_U: *const Number,
311    nele_jac: Index,
312    nele_hess: Index,
313    index_style: Index,
314    eval_f: Option<Eval_F_CB>,
315    eval_g: Option<Eval_G_CB>,
316    eval_grad_f: Option<Eval_Grad_F_CB>,
317    eval_jac_g: Option<Eval_Jac_G_CB>,
318    eval_h: Option<Eval_H_CB>,
319) -> IpoptProblem {
320    unsafe {
321        // Install the tracing subscriber on first use so C consumers
322        // (cyipopt, AMPL, …) get logging and the iteration collector that
323        // backs `IpoptEnableIterHistory` (pounce#71). Idempotent.
324        pounce_observability::init_subscriber();
325
326        if n < 0 || m < 0 || nele_jac < 0 || nele_hess < 0 {
327            return std::ptr::null_mut();
328        }
329        if !(0..=1).contains(&index_style) {
330            return std::ptr::null_mut();
331        }
332        if eval_f.is_none() || eval_grad_f.is_none() {
333            return std::ptr::null_mut();
334        }
335        if m > 0 && (eval_g.is_none() || eval_jac_g.is_none()) {
336            return std::ptr::null_mut();
337        }
338        if n > 0 && (x_L.is_null() || x_U.is_null()) {
339            return std::ptr::null_mut();
340        }
341        if m > 0 && (g_L.is_null() || g_U.is_null()) {
342            return std::ptr::null_mut();
343        }
344
345        let x_l = if n > 0 {
346            std::slice::from_raw_parts(x_L, n as usize).to_vec()
347        } else {
348            Vec::new()
349        };
350        let x_u = if n > 0 {
351            std::slice::from_raw_parts(x_U, n as usize).to_vec()
352        } else {
353            Vec::new()
354        };
355        let g_l_vec = if m > 0 {
356            std::slice::from_raw_parts(g_L, m as usize).to_vec()
357        } else {
358            Vec::new()
359        };
360        let g_u_vec = if m > 0 {
361            std::slice::from_raw_parts(g_U, m as usize).to_vec()
362        } else {
363            Vec::new()
364        };
365
366        let info = Box::new(IpoptProblemInfo {
367            app: IpoptApplication::new(),
368            n,
369            m,
370            nele_jac,
371            nele_hess,
372            index_style,
373            x_l,
374            x_u,
375            g_l: g_l_vec,
376            g_u: g_u_vec,
377            eval_f,
378            eval_g,
379            eval_grad_f,
380            eval_jac_g,
381            eval_h,
382            intermediate_cb: None,
383            user_scaling: None,
384            nonlinear_vars: None,
385            last_solve: None,
386            pending_working_set: None,
387        });
388        Box::into_raw(info)
389    }
390}
391
392/// Port of `IpStdCInterface.cpp:FreeIpoptProblem`.
393///
394/// # Safety
395///
396/// `ipopt_problem` must be a pointer previously returned by
397/// [`CreateIpoptProblem`] and not yet freed, or NULL.
398#[unsafe(no_mangle)]
399pub unsafe extern "C" fn FreeIpoptProblem(ipopt_problem: IpoptProblem) {
400    unsafe {
401        if ipopt_problem.is_null() {
402            return;
403        }
404        drop(Box::from_raw(ipopt_problem));
405    }
406}
407
408unsafe fn keyword_str<'a>(keyword: *const c_char) -> Option<&'a str> {
409    unsafe {
410        if keyword.is_null() {
411            return None;
412        }
413        CStr::from_ptr(keyword).to_str().ok()
414    }
415}
416
417/// Port of `IpStdCInterface.cpp:AddIpoptStrOption`.
418///
419/// # Safety
420///
421/// `ipopt_problem` must be a valid `IpoptProblem`. `keyword` and `val`
422/// must be valid NUL-terminated strings.
423#[unsafe(no_mangle)]
424pub unsafe extern "C" fn AddIpoptStrOption(
425    ipopt_problem: IpoptProblem,
426    keyword: *const c_char,
427    val: *const c_char,
428) -> Bool {
429    unsafe {
430        if ipopt_problem.is_null() {
431            return FALSE;
432        }
433        let info = &mut *ipopt_problem;
434        let Some(k) = keyword_str(keyword) else {
435            return FALSE;
436        };
437        if val.is_null() {
438            return FALSE;
439        }
440        let Ok(v) = CStr::from_ptr(val).to_str() else {
441            return FALSE;
442        };
443        match info.app.options_mut().set_string_value(k, v, true, false) {
444            Ok(_) => TRUE,
445            Err(_) => FALSE,
446        }
447    }
448}
449
450/// Port of `AddIpoptNumOption`.
451///
452/// # Safety
453///
454/// `keyword` must be a valid NUL-terminated string and
455/// `ipopt_problem` must be a valid `IpoptProblem`.
456#[unsafe(no_mangle)]
457pub unsafe extern "C" fn AddIpoptNumOption(
458    ipopt_problem: IpoptProblem,
459    keyword: *const c_char,
460    val: Number,
461) -> Bool {
462    unsafe {
463        if ipopt_problem.is_null() {
464            return FALSE;
465        }
466        let info = &mut *ipopt_problem;
467        let Some(k) = keyword_str(keyword) else {
468            return FALSE;
469        };
470        match info
471            .app
472            .options_mut()
473            .set_numeric_value(k, val, true, false)
474        {
475            Ok(_) => TRUE,
476            Err(_) => FALSE,
477        }
478    }
479}
480
481/// Port of `AddIpoptIntOption`.
482///
483/// # Safety
484///
485/// `keyword` must be a valid NUL-terminated string and
486/// `ipopt_problem` must be a valid `IpoptProblem`.
487#[unsafe(no_mangle)]
488pub unsafe extern "C" fn AddIpoptIntOption(
489    ipopt_problem: IpoptProblem,
490    keyword: *const c_char,
491    val: Index,
492) -> Bool {
493    unsafe {
494        if ipopt_problem.is_null() {
495            return FALSE;
496        }
497        let info = &mut *ipopt_problem;
498        let Some(k) = keyword_str(keyword) else {
499            return FALSE;
500        };
501        match info.app.options_mut().set_integer_value(
502            k,
503            val as pounce_common::types::Index,
504            true,
505            false,
506        ) {
507            Ok(_) => TRUE,
508            Err(_) => FALSE,
509        }
510    }
511}
512
513/// Port of `IpStdCInterface.cpp:OpenIpoptOutputFile`. Opens `file_name`
514/// at `print_level` and attaches a journalist `FileJournal` so all
515/// solver output is mirrored to disk. Equivalent to setting
516/// `output_file` + `file_print_level` options and triggering
517/// `IpoptApplication::Initialize`.
518///
519/// Returns `TRUE` (1) on success, `FALSE` (0) if the file could not
520/// be opened or the option store rejected the value.
521///
522/// # Safety
523///
524/// `ipopt_problem` must be a valid `IpoptProblem`. `file_name` must
525/// be a valid NUL-terminated string.
526#[unsafe(no_mangle)]
527pub unsafe extern "C" fn OpenIpoptOutputFile(
528    ipopt_problem: IpoptProblem,
529    file_name: *const c_char,
530    print_level: c_int,
531) -> Bool {
532    unsafe {
533        if ipopt_problem.is_null() || file_name.is_null() {
534            return FALSE;
535        }
536        let info = &mut *ipopt_problem;
537        let Ok(fname) = CStr::from_ptr(file_name).to_str() else {
538            return FALSE;
539        };
540        if info.app.open_output_file(fname, print_level) {
541            TRUE
542        } else {
543            FALSE
544        }
545    }
546}
547
548/// Port of `IpStdCInterface.cpp:SetIpoptProblemScaling`. Stores
549/// user-provided NLP scaling on the problem; the scaling is forwarded
550/// to the solver via [`TNLP::get_scaling_parameters`] when the option
551/// `nlp_scaling_method=user-scaling` is set. Passing NULL for
552/// `x_scaling` / `g_scaling` disables scaling on that axis.
553///
554/// Always returns `TRUE` (the upstream contract). A non-trivial
555/// `x_scaling` is applied as a change of variables, so the solution and
556/// bound multipliers `IpoptSolve` writes back are in the caller's own
557/// units (gh#486). A factor that is not finite and positive is refused
558/// at solve time, where [`IpoptSolve`] returns `Invalid_Option` and the
559/// journalist explains why: store-time validation is not an option,
560/// because the C signature has no way to report it.
561///
562/// # Safety
563///
564/// `ipopt_problem` must be a valid `IpoptProblem`. When non-NULL,
565/// `x_scaling` must point to `n` doubles and `g_scaling` to `m`
566/// doubles; both arrays are copied internally.
567#[unsafe(no_mangle)]
568pub unsafe extern "C" fn SetIpoptProblemScaling(
569    ipopt_problem: IpoptProblem,
570    obj_scaling: Number,
571    x_scaling: *const Number,
572    g_scaling: *const Number,
573) -> Bool {
574    unsafe {
575        if ipopt_problem.is_null() {
576            return FALSE;
577        }
578        let info = &mut *ipopt_problem;
579        let n = info.n as usize;
580        let m = info.m as usize;
581        let x_vec = if !x_scaling.is_null() && n > 0 {
582            Some(std::slice::from_raw_parts(x_scaling, n).to_vec())
583        } else {
584            None
585        };
586        let g_vec = if !g_scaling.is_null() && m > 0 {
587            Some(std::slice::from_raw_parts(g_scaling, m).to_vec())
588        } else {
589            None
590        };
591        info.user_scaling = Some(UserScaling {
592            obj_scaling,
593            x_scaling: x_vec,
594            g_scaling: g_vec,
595        });
596        TRUE
597    }
598}
599
600/// Port of `IpStdCInterface.cpp:IpoptSolve`. Returns the
601/// `ApplicationReturnStatus` integer.
602///
603/// Builds a [`CCallbackTnlp`] from the user-supplied callback table
604/// and bounds, runs it through [`IpoptApplication::optimize_tnlp`],
605/// and writes back the final iterate.
606///
607/// # Safety
608///
609/// All pointer arguments are read/written per the
610/// `IpStdCInterface.h` contract: `x` is in/out (size `n`); `g`,
611/// `mult_g`, `mult_x_L`, `mult_x_U` are out-only (sizes `m, m, n, n`)
612/// and may be NULL when the corresponding output is not desired.
613#[allow(clippy::too_many_arguments)]
614#[unsafe(no_mangle)]
615pub unsafe extern "C" fn IpoptSolve(
616    ipopt_problem: IpoptProblem,
617    x: *mut Number,
618    g: *mut Number,
619    obj_val: *mut Number,
620    mult_g: *mut Number,
621    mult_x_L: *mut Number,
622    mult_x_U: *mut Number,
623    user_data: *mut c_void,
624) -> Index {
625    unsafe {
626        if ipopt_problem.is_null() {
627            return ApplicationReturnStatus::InternalError as Index;
628        }
629        // Invalidate the retained stats up front, before the solve is attempted.
630        // The `last_solve` snapshot is only repopulated at the *end* of a
631        // completed solve, so if the guarded body below bails early or a panic is
632        // caught (returning `Internal_Error`), the post-solve accessors
633        // (`GetIpoptIterCount`, `IpoptWriteSolveReport`, …) must not silently
634        // report the *previous* solve's stats. Clearing here makes the
635        // failure-consistent state "no data" rather than stale data (F5).
636        (*ipopt_problem).last_solve = None;
637        // Guard the whole solve: `optimize_tnlp` runs the entire pounce core and
638        // callback bridge, any of which could panic on an unexpected internal
639        // state. Without this, such a panic would unwind across `extern "C"` and
640        // abort the embedding process; instead we report `Internal_Error`,
641        // matching upstream Ipopt's exception handling. (See `ffi_guard`.)
642        ffi_guard(ApplicationReturnStatus::InternalError as Index, || {
643            let info = &mut *ipopt_problem;
644            if info.n < 0 || info.m < 0 {
645                return ApplicationReturnStatus::InvalidProblemDefinition as Index;
646            }
647            if info.n > 0 && x.is_null() {
648                return ApplicationReturnStatus::InvalidProblemDefinition as Index;
649            }
650
651            let n_us = info.n as usize;
652            let m_us = info.m as usize;
653            let initial_x = if n_us > 0 {
654                std::slice::from_raw_parts(x, n_us).to_vec()
655            } else {
656                Vec::new()
657            };
658
659            // Merge any working set staged by
660            // `IpoptSetWarmStartWorkingSet` with the iterate the caller
661            // actually supplied. This is the point at which the primal
662            // starting point is known, so it is the only correct place
663            // to build the `SqpIterates` (gh#484).
664            //
665            // Duals follow upstream Ipopt's `IpoptSolve` contract:
666            // `mult_g` / `mult_x_L` / `mult_x_U` are inputs only when
667            // `warm_start_init_point=yes`, and out-only otherwise. A
668            // caller who has not opted in may pass uninitialized
669            // buffers, so reading them unconditionally would seed the
670            // SQP with garbage multipliers.
671            if let Some(working) = info.pending_working_set.take() {
672                let seed_duals = matches!(
673                    info.app
674                        .options()
675                        .get_bool_value("warm_start_init_point", ""),
676                    Ok((true, true))
677                );
678                let read_in = |p: *const Number, len: usize| -> Vec<Number> {
679                    if seed_duals && !p.is_null() && len > 0 {
680                        std::slice::from_raw_parts(p, len).to_vec()
681                    } else {
682                        vec![0.0; len]
683                    }
684                };
685                let lambda_g = read_in(mult_g as *const Number, m_us);
686                let z_l = read_in(mult_x_L as *const Number, n_us);
687                let z_u = read_in(mult_x_U as *const Number, n_us);
688                // SQP packs the bound multipliers signed, as
689                // `lambda_x = z_l − z_u` (see `sqp::warm_start`).
690                let lambda_x = z_l.iter().zip(&z_u).map(|(l, u)| l - u).collect();
691                info.app
692                    .set_sqp_warm_start(pounce_algorithm::sqp::SqpIterates {
693                        x: initial_x.clone(),
694                        lambda_g,
695                        lambda_x,
696                        working: Some(working),
697                    });
698            }
699
700            let bridge = Rc::new(RefCell::new(CCallbackTnlp {
701                n: info.n,
702                m: info.m,
703                nele_jac: info.nele_jac,
704                nele_hess: info.nele_hess,
705                index_style: info.index_style,
706                x_l: info.x_l.clone(),
707                x_u: info.x_u.clone(),
708                g_l: info.g_l.clone(),
709                g_u: info.g_u.clone(),
710                initial_x,
711                eval_f: info.eval_f,
712                eval_grad_f: info.eval_grad_f,
713                eval_g: info.eval_g,
714                eval_jac_g: info.eval_jac_g,
715                eval_h: info.eval_h,
716                user_data,
717                intermediate_cb: info.intermediate_cb,
718                user_scaling: info.user_scaling.clone(),
719                nonlinear_vars: info.nonlinear_vars.clone(),
720                final_status: None,
721                final_x: vec![0.0; n_us],
722                final_z_l: vec![0.0; n_us],
723                final_z_u: vec![0.0; n_us],
724                final_g: vec![0.0; m_us],
725                final_lambda: vec![0.0; m_us],
726                final_obj: 0.0,
727            }));
728
729            // Wire the restoration phase fresh for this solve. Without it, any
730            // line-search failure surfaces as `RestorationFailure` instead of
731            // falling back into the ℓ1-feasibility sub-IPM — exactly what the
732            // CLI driver does. Re-wire per `IpoptSolve` to stay correct across
733            // repeated solves on the same `IpoptProblem`. The feral config is
734            // snapshot from the now-fully-populated options so `feral_*`
735            // overrides flow into the restoration sub-IPM too. Use the multi-pass
736            // provider so the ℓ₁ wrapper / auto-fallback don't panic on the
737            // second inner solve (pounce#10 Phase 3 / pounce#24).
738            let feral_cfg = feral_config_from_options(info.app.options());
739            // The `ma57_*` options under the `"resto."` prefix — dead until
740            // gh#825, because nothing threaded any MA57 config into a factory.
741            let ma57_cfg = ma57_config_from_options(info.app.options(), "resto.");
742            let bff_mint = move || -> InnerBackendFactoryFactory {
743                let feral_cfg = feral_cfg.clone();
744                let ma57_cfg = ma57_cfg.clone();
745                Box::new(move || default_backend_factory(feral_cfg.clone(), ma57_cfg.clone()))
746            };
747            let resto_provider = make_default_restoration_factory_provider(
748                RestoAlgorithmBuilder::new(),
749                info.app.algorithm_builder_from_options(),
750                bff_mint,
751            );
752            info.app.set_restoration_factory_provider(resto_provider);
753
754            let bridge_for_solve: Rc<RefCell<dyn TNLP>> = bridge.clone();
755            let status = info.app.optimize_tnlp(bridge_for_solve);
756            let stats = info.app.statistics();
757            // Second-opinion ladder, on by default as it is in the CLI and the
758            // Python frontend: an `Infeasible_Problem_Detected` or
759            // `Invalid_Number_Detected` is re-solved along up to three
760            // deliberately different trajectories and a re-solve is promoted
761            // only if it converges. A converged solve pays nothing — the
762            // ladder reads the status and returns. The three `*_retry`
763            // options turn individual rungs off.
764            //
765            // Narration goes to stderr, where the solver's own banners already
766            // go -- but gated on `print_level >= 1`. This crate is the Ipopt
767            // drop-in, so `print_level=0 sb=yes` is a caller asking for
768            // silence, and eight unexpected `pounce:` lines on a failing
769            // solve is exactly what that asks not to happen. The ladder still runs;
770            // only the console is quiet.
771            let narrate = pounce_algorithm::second_opinion::narration_is_wanted(info.app.options());
772            let ladder = run_second_opinion_ladder(
773                &mut info.app,
774                bridge.clone() as Rc<RefCell<dyn TNLP>>,
775                status,
776                stats,
777                &mut |line| {
778                    if narrate {
779                        eprintln!("{line}");
780                    }
781                },
782            );
783            let status = ladder.status;
784            let bridge_ref = bridge.borrow();
785            info.last_solve = Some(LastSolve {
786                stats: ladder.statistics.clone(),
787                status,
788                linear_solver: info.app.linear_solver_summary(),
789                final_x: bridge_ref.final_x.clone(),
790                final_lambda: bridge_ref.final_lambda.clone(),
791                final_obj: bridge_ref.final_obj,
792            });
793            if !x.is_null() && n_us > 0 {
794                std::ptr::copy_nonoverlapping(bridge_ref.final_x.as_ptr(), x, n_us);
795            }
796            if !g.is_null() && m_us > 0 {
797                std::ptr::copy_nonoverlapping(bridge_ref.final_g.as_ptr(), g, m_us);
798            }
799            if !obj_val.is_null() {
800                *obj_val = bridge_ref.final_obj;
801            }
802            if !mult_g.is_null() && m_us > 0 {
803                std::ptr::copy_nonoverlapping(bridge_ref.final_lambda.as_ptr(), mult_g, m_us);
804            }
805            if !mult_x_L.is_null() && n_us > 0 {
806                std::ptr::copy_nonoverlapping(bridge_ref.final_z_l.as_ptr(), mult_x_L, n_us);
807            }
808            if !mult_x_U.is_null() && n_us > 0 {
809                std::ptr::copy_nonoverlapping(bridge_ref.final_z_u.as_ptr(), mult_x_U, n_us);
810            }
811            status as Index
812        })
813    }
814}
815
816/// Port of `SetIntermediateCallback`.
817///
818/// # Safety
819///
820/// `ipopt_problem` must be valid.
821#[unsafe(no_mangle)]
822pub unsafe extern "C" fn SetIntermediateCallback(
823    ipopt_problem: IpoptProblem,
824    intermediate_cb: Option<Intermediate_CB>,
825) -> Bool {
826    unsafe {
827        if ipopt_problem.is_null() {
828            return FALSE;
829        }
830        let info = &mut *ipopt_problem;
831        info.intermediate_cb = intermediate_cb;
832        TRUE
833    }
834}
835
836/// Port of `IpStdCInterface.cpp:GetIpoptCurrentIterate` (Ipopt 3.14+).
837/// Designed to be called from inside an intermediate callback to
838/// inspect `x`, the bound multipliers `z_L/z_U`, the constraint values
839/// `g`, and the constraint multipliers `lambda` at the current
840/// iterate.
841///
842/// All output buffers are optional — pass NULL to skip. `n` and `m`
843/// must match the dimensions the problem was created with; mismatched
844/// sizes cause the function to return `FALSE` without writing.
845///
846/// `scaled` is currently ignored — quantities are reported in the
847/// user TNLP's unscaled space (matching upstream Ipopt's default
848/// caller behavior when scaling is unused). Honoring `scaled` for the
849/// `gradient-based` scaler is a follow-up.
850///
851/// Returns `FALSE` when called outside an active intermediate
852/// callback (no live iterate to inspect).
853///
854/// # Safety
855///
856/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
857/// when non-NULL, must hold at least the declared length.
858#[allow(clippy::too_many_arguments)]
859#[unsafe(no_mangle)]
860pub unsafe extern "C" fn GetIpoptCurrentIterate(
861    ipopt_problem: IpoptProblem,
862    _scaled: Bool,
863    n: Index,
864    x: *mut Number,
865    z_l: *mut Number,
866    z_u: *mut Number,
867    m: Index,
868    g: *mut Number,
869    lambda: *mut Number,
870) -> Bool {
871    unsafe {
872        if ipopt_problem.is_null() {
873            return FALSE;
874        }
875        let info = &*ipopt_problem;
876        if n != info.n || m != info.m {
877            return FALSE;
878        }
879        let result = ip_intermediate::with_current(|ctx| {
880            // Snapshot the iterate handles and release the `data` borrow
881            // before touching `cq`: several `IpoptCq` accessors
882            // (`curr_c`, `curr_d`, …) evaluate through the NLP and take
883            // `nlp.borrow_mut()` internally, so no `nlp`/`data` borrow may
884            // be alive across them. Holding one here panicked
885            // ("RefCell already borrowed") on every `g != NULL` call, and
886            // a panic across this `extern "C"` boundary aborts the
887            // process rather than returning `FALSE`.
888            let curr = {
889                let data = ctx.data.borrow();
890                match data.curr.as_ref() {
891                    Some(curr) => curr.clone(),
892                    None => return false,
893                }
894            };
895            let n_us = n as usize;
896            let m_us = m as usize;
897            if !x.is_null() && n_us > 0 {
898                let full_x = ctx.nlp.borrow().lift_x_to_full(&*curr.x);
899                if full_x.len() != n_us {
900                    return false;
901                }
902                std::ptr::copy_nonoverlapping(full_x.as_ptr(), x, n_us);
903            }
904            if !z_l.is_null() && n_us > 0 {
905                let full = ctx.nlp.borrow().pack_z_l_for_user(&*curr.z_l);
906                if full.len() != n_us {
907                    return false;
908                }
909                std::ptr::copy_nonoverlapping(full.as_ptr(), z_l, n_us);
910            }
911            if !z_u.is_null() && n_us > 0 {
912                let full = ctx.nlp.borrow().pack_z_u_for_user(&*curr.z_u);
913                if full.len() != n_us {
914                    return false;
915                }
916                std::ptr::copy_nonoverlapping(full.as_ptr(), z_u, n_us);
917            }
918            if !g.is_null() && m_us > 0 {
919                // `curr_c` / `curr_d` re-enter the NLP mutably: evaluate
920                // them first, *then* borrow `nlp` to pack the result.
921                let (c, d) = {
922                    let cq = ctx.cq.borrow();
923                    (cq.curr_c(), cq.curr_d())
924                };
925                let full = ctx.nlp.borrow().pack_g_for_user(&*c, &*d);
926                if full.len() != m_us {
927                    return false;
928                }
929                std::ptr::copy_nonoverlapping(full.as_ptr(), g, m_us);
930            }
931            if !lambda.is_null() && m_us > 0 {
932                let full = ctx
933                    .nlp
934                    .borrow()
935                    .pack_lambda_for_user(&*curr.y_c, &*curr.y_d);
936                if full.len() != m_us {
937                    return false;
938                }
939                std::ptr::copy_nonoverlapping(full.as_ptr(), lambda, m_us);
940            }
941            true
942        });
943        if result.unwrap_or(false) { TRUE } else { FALSE }
944    }
945}
946
947/// Port of `IpStdCInterface.cpp:GetIpoptCurrentViolations` (Ipopt 3.14+).
948/// Same contract as [`GetIpoptCurrentIterate`]; returns `FALSE` when
949/// called outside an active intermediate callback.
950///
951/// `scaled` is currently ignored — see [`GetIpoptCurrentIterate`].
952/// Violations and complementarities are reported in the compressed
953/// algorithm-side space scattered out to full-`n`/`m`; this is the
954/// shape upstream callers consume (zero-fill for free positions /
955/// no-bound positions).
956///
957/// # Safety
958///
959/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
960/// when non-NULL, must hold at least the declared length.
961#[allow(clippy::too_many_arguments)]
962#[unsafe(no_mangle)]
963pub unsafe extern "C" fn GetIpoptCurrentViolations(
964    ipopt_problem: IpoptProblem,
965    _scaled: Bool,
966    n: Index,
967    x_l_violation: *mut Number,
968    x_u_violation: *mut Number,
969    compl_x_l: *mut Number,
970    compl_x_u: *mut Number,
971    grad_lag_x: *mut Number,
972    m: Index,
973    nlp_constraint_violation: *mut Number,
974    compl_g: *mut Number,
975) -> Bool {
976    unsafe {
977        if ipopt_problem.is_null() {
978            return FALSE;
979        }
980        let info = &*ipopt_problem;
981        if n != info.n || m != info.m {
982            return FALSE;
983        }
984        let result = ip_intermediate::with_current(|ctx| {
985            let data = ctx.data.borrow();
986            let Some(_curr) = data.curr.as_ref() else {
987                return false;
988            };
989            drop(data);
990            let cq = ctx.cq.borrow();
991            let n_us = n as usize;
992            let m_us = m as usize;
993            // No `nlp` borrow may be held across a `cq` accessor that
994            // evaluates through the NLP (`curr_grad_lag_x` reaches
995            // `curr_grad_f`, which takes `nlp.borrow_mut()`); each branch
996            // below therefore borrows `nlp` only to pack a value that has
997            // already been computed. See `GetIpoptCurrentIterate`.
998            // x_L / x_U violations: scatter the compressed slack-shortfalls
999            // up to full-`n`. Upstream defines `x_L_violation_i = max(0, x_L_i
1000            // - x_i)`; the algorithm tracks `slack_x_l = P_L^T x - x_L`
1001            // (always non-negative at feasible iterates), so reverse the
1002            // sign and clamp.
1003            if !x_l_violation.is_null() && n_us > 0 {
1004                let slack = cq.curr_slack_x_l();
1005                let z_l_full = ctx.nlp.borrow().pack_z_l_for_user(&*slack);
1006                // Guard the scatter length exactly like the sibling branches
1007                // below: an unexpected packed length would otherwise index
1008                // `v[i]` out of bounds and panic across this `extern "C"`
1009                // boundary (an abort, not a recoverable error).
1010                if z_l_full.len() != n_us {
1011                    return false;
1012                }
1013                // pack_z_l_for_user scatters by the same x_L mapping; the
1014                // returned vector at full-x positions holds `slack_x_l[i]`
1015                // which is `x_i - x_L_i`. Clamp the *negative* part to get
1016                // the violation `max(0, x_L_i - x_i)`.
1017                let mut v = vec![0.0; n_us];
1018                for (i, s) in z_l_full.iter().enumerate() {
1019                    v[i] = (-s).max(0.0);
1020                }
1021                std::ptr::copy_nonoverlapping(v.as_ptr(), x_l_violation, n_us);
1022            }
1023            if !x_u_violation.is_null() && n_us > 0 {
1024                let slack = cq.curr_slack_x_u();
1025                let s_full = ctx.nlp.borrow().pack_z_u_for_user(&*slack);
1026                if s_full.len() != n_us {
1027                    return false;
1028                }
1029                let mut v = vec![0.0; n_us];
1030                for (i, s) in s_full.iter().enumerate() {
1031                    v[i] = (-s).max(0.0);
1032                }
1033                std::ptr::copy_nonoverlapping(v.as_ptr(), x_u_violation, n_us);
1034            }
1035            if !compl_x_l.is_null() && n_us > 0 {
1036                let compl = cq.curr_compl_x_l();
1037                let v = ctx.nlp.borrow().pack_z_l_for_user(&*compl);
1038                if v.len() != n_us {
1039                    return false;
1040                }
1041                std::ptr::copy_nonoverlapping(v.as_ptr(), compl_x_l, n_us);
1042            }
1043            if !compl_x_u.is_null() && n_us > 0 {
1044                let compl = cq.curr_compl_x_u();
1045                let v = ctx.nlp.borrow().pack_z_u_for_user(&*compl);
1046                if v.len() != n_us {
1047                    return false;
1048                }
1049                std::ptr::copy_nonoverlapping(v.as_ptr(), compl_x_u, n_us);
1050            }
1051            if !grad_lag_x.is_null() && n_us > 0 {
1052                let glx = cq.curr_grad_lag_x();
1053                // Scatter compressed x-var → full-x via lift_x_to_full
1054                // (treats `glx` as if it were an x-vector). Fixed-variable
1055                // slots remain zero.
1056                let full = ctx.nlp.borrow().lift_x_to_full(&*glx);
1057                if full.len() != n_us {
1058                    return false;
1059                }
1060                std::ptr::copy_nonoverlapping(full.as_ptr(), grad_lag_x, n_us);
1061            }
1062            if !nlp_constraint_violation.is_null() && m_us > 0 {
1063                // Per-row equality and range violation reconstruction in
1064                // full-g coordinates is a follow-up. The scalar
1065                // `curr_primal_infeasibility_max` (== `inf_pr` reported in
1066                // `IterStats`) is the outer summary; populate per-row
1067                // detail as a future refinement and zero-fill for now.
1068                let zero = vec![0.0; m_us];
1069                std::ptr::copy_nonoverlapping(zero.as_ptr(), nlp_constraint_violation, m_us);
1070            }
1071            if !compl_g.is_null() && m_us > 0 {
1072                // Per-row constraint complementarity (`v_L .* s_L` /
1073                // `v_U .* s_U` mapped back to full-g) is also a follow-up.
1074                let zero = vec![0.0; m_us];
1075                std::ptr::copy_nonoverlapping(zero.as_ptr(), compl_g, m_us);
1076            }
1077            true
1078        });
1079        if result.unwrap_or(false) { TRUE } else { FALSE }
1080    }
1081}
1082
1083/// Port of `IpStdCInterface.cpp:GetIpoptVersion` (Ipopt 3.14.18+).
1084/// Writes the pounce crate's `major.minor.patch` into the buffers.
1085/// Any pointer may be NULL to skip that component.
1086///
1087/// # Safety
1088///
1089/// Each non-NULL pointer must point at a writable `int`.
1090#[unsafe(no_mangle)]
1091pub unsafe extern "C" fn GetIpoptVersion(
1092    major: *mut c_int,
1093    minor: *mut c_int,
1094    release: *mut c_int,
1095) {
1096    unsafe {
1097        // Read from Cargo at compile time so the symbol always matches the
1098        // shipped binary. `unwrap_or(0)` keeps the function infallible if a
1099        // component is missing from the manifest (shouldn't happen in
1100        // practice — workspace manifest requires SemVer triples).
1101        let (mj, mn, pt) = parse_pkg_version(env!("CARGO_PKG_VERSION"));
1102        if !major.is_null() {
1103            *major = mj;
1104        }
1105        if !minor.is_null() {
1106            *minor = mn;
1107        }
1108        if !release.is_null() {
1109            *release = pt;
1110        }
1111    }
1112}
1113
1114fn parse_pkg_version(v: &str) -> (c_int, c_int, c_int) {
1115    let mut it = v.split('.').map(|s| s.parse::<c_int>().unwrap_or(0));
1116    (
1117        it.next().unwrap_or(0),
1118        it.next().unwrap_or(0),
1119        it.next().unwrap_or(0),
1120    )
1121}
1122
1123// ----------------------------------------------------------------------
1124// Pounce extensions: post-solve statistics accessors.
1125//
1126// Convenience accessors not present in upstream Ipopt's C API. Valid
1127// only after [`IpoptSolve`] has returned; calling them on a
1128// never-solved problem yields zero. They expose the same
1129// `SolveStatistics` data the Rust API surfaces via
1130// [`IpoptApplication::statistics`].
1131// ----------------------------------------------------------------------
1132
1133/// Number of IPM iterations in the most recent solve, or `0` if the
1134/// problem has not been solved yet.
1135///
1136/// # Safety
1137///
1138/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1139#[unsafe(no_mangle)]
1140pub unsafe extern "C" fn GetIpoptIterCount(ipopt_problem: IpoptProblem) -> Index {
1141    unsafe { last_stat(ipopt_problem, |s| s.iteration_count).unwrap_or(0) }
1142}
1143
1144/// Wall-clock solve time in seconds for the most recent solve, or
1145/// `0.0` if the problem has not been solved yet.
1146///
1147/// # Safety
1148///
1149/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1150#[unsafe(no_mangle)]
1151pub unsafe extern "C" fn GetIpoptSolveTime(ipopt_problem: IpoptProblem) -> Number {
1152    unsafe { last_stat(ipopt_problem, |s| s.total_wallclock_time_secs).unwrap_or(0.0) }
1153}
1154
1155/// Final primal infeasibility (max constraint violation) for the most
1156/// recent solve.
1157///
1158/// # Safety
1159///
1160/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1161#[unsafe(no_mangle)]
1162pub unsafe extern "C" fn GetIpoptPrimalInf(ipopt_problem: IpoptProblem) -> Number {
1163    unsafe { last_stat(ipopt_problem, |s| s.final_constr_viol).unwrap_or(0.0) }
1164}
1165
1166/// Final dual infeasibility (max gradient-of-Lagrangian norm) for the
1167/// most recent solve.
1168///
1169/// # Safety
1170///
1171/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1172#[unsafe(no_mangle)]
1173pub unsafe extern "C" fn GetIpoptDualInf(ipopt_problem: IpoptProblem) -> Number {
1174    unsafe { last_stat(ipopt_problem, |s| s.final_dual_inf).unwrap_or(0.0) }
1175}
1176
1177/// Final complementarity error for the most recent solve.
1178///
1179/// # Safety
1180///
1181/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1182#[unsafe(no_mangle)]
1183pub unsafe extern "C" fn GetIpoptComplInf(ipopt_problem: IpoptProblem) -> Number {
1184    unsafe { last_stat(ipopt_problem, |s| s.final_compl).unwrap_or(0.0) }
1185}
1186
1187unsafe fn last_stat<T, F>(ipopt_problem: IpoptProblem, f: F) -> Option<T>
1188where
1189    F: FnOnce(&SolveStatistics) -> T,
1190{
1191    unsafe {
1192        if ipopt_problem.is_null() {
1193            return None;
1194        }
1195        (*ipopt_problem).last_solve.as_ref().map(|ls| f(&ls.stats))
1196    }
1197}
1198
1199/// Restoration-phase activity in the most recent solve. Each output
1200/// may be NULL to skip it; all yield zero before the first solve.
1201///
1202/// Solve-level, and still worth having now that the intermediate
1203/// callback also fires from restoration with `alg_mod = 1` (gh#645):
1204/// these counters answer "how much restoration did this solve do?"
1205/// without the caller having to install a callback and tally fires,
1206/// and they are the only source for the inner iteration count and
1207/// wall time.
1208///
1209/// # Safety
1210///
1211/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. Each
1212/// non-NULL output pointer must be writable.
1213#[unsafe(no_mangle)]
1214pub unsafe extern "C" fn GetPounceRestorationStats(
1215    ipopt_problem: IpoptProblem,
1216    calls: *mut Index,
1217    inner_iters: *mut Index,
1218    outer_iters: *mut Index,
1219    wall_secs: *mut Number,
1220) {
1221    unsafe {
1222        let stats = last_stat(ipopt_problem, |s| {
1223            (
1224                s.restoration_calls,
1225                s.restoration_inner_iters,
1226                s.restoration_outer_iters,
1227                s.restoration_wall_secs,
1228            )
1229        });
1230        let (c, i, o, w) = stats.unwrap_or((0, 0, 0, 0.0));
1231        if !calls.is_null() {
1232            *calls = c;
1233        }
1234        if !inner_iters.is_null() {
1235            *inner_iters = i;
1236        }
1237        if !outer_iters.is_null() {
1238            *outer_iters = o;
1239        }
1240        if !wall_secs.is_null() {
1241            *wall_secs = w;
1242        }
1243    }
1244}
1245
1246/// Finite-difference Hessian census from the most recent solve. Any
1247/// pointer may be NULL to skip that component.
1248///
1249/// All outputs are left at their "did not run" values on any solve that
1250/// was not `hessian_approximation=finite-difference`: `pattern_used`
1251/// is `-1` and the counts are `0`. `pattern_used` is `0` for the
1252/// declared pattern and `1` for the Jacobian-derived one, and it names
1253/// what the run **ended up with** — `declared` falls back to `jacobian`
1254/// when the TNLP declares no Hessian structure, and that fallback is
1255/// what the number is worth reading for.
1256///
1257/// # Safety
1258///
1259/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. Each
1260/// non-NULL output pointer must be writable.
1261#[unsafe(no_mangle)]
1262pub unsafe extern "C" fn GetPounceFdHessianStats(
1263    ipopt_problem: IpoptProblem,
1264    pattern_used: *mut Index,
1265    nnz: *mut Index,
1266    n: *mut Index,
1267    groups: *mut Index,
1268    rho_max: *mut Index,
1269    coloring_fell_back: *mut Index,
1270    objective_clique_widened: *mut Index,
1271) {
1272    unsafe {
1273        let stats = last_stat(ipopt_problem, |s| {
1274            (
1275                s.fd_hessian_pattern_used,
1276                s.fd_hessian_nnz,
1277                s.fd_hessian_n,
1278                s.fd_hessian_groups,
1279                s.fd_hessian_rho_max,
1280                if s.fd_hessian_coloring_fell_back {
1281                    1
1282                } else {
1283                    0
1284                },
1285                if s.fd_hessian_objective_clique_widened {
1286                    1
1287                } else {
1288                    0
1289                },
1290            )
1291        });
1292        let (p, nz, cols, g, r, f, w) = stats.unwrap_or((-1, 0, 0, 0, 0, 0, 0));
1293        if !pattern_used.is_null() {
1294            *pattern_used = p;
1295        }
1296        if !nnz.is_null() {
1297            *nnz = nz;
1298        }
1299        if !n.is_null() {
1300            *n = cols;
1301        }
1302        if !groups.is_null() {
1303            *groups = g;
1304        }
1305        if !rho_max.is_null() {
1306            *rho_max = r;
1307        }
1308        if !coloring_fell_back.is_null() {
1309            *coloring_fell_back = f;
1310        }
1311        if !objective_clique_widened.is_null() {
1312            *objective_clique_widened = w;
1313        }
1314    }
1315}
1316
1317/// C mirror of [`pounce_linsol::summary::LinearSolverSummary`], laid
1318/// out for `pounce.h`'s `PounceLinearSolverStats`. Optional fields
1319/// carry sentinels rather than a discriminant, because a plain struct
1320/// of scalars is what a C or C++ caller can consume without an
1321/// accessor per field: `NaN` for absent reals, `-1` for absent counts.
1322#[repr(C)]
1323#[derive(Debug, Clone, Copy)]
1324pub struct PounceLinearSolverStats {
1325    pub solver_name: [c_char; 32],
1326    pub n_factors: Index,
1327    pub n_pattern_reuse: Index,
1328    pub n_pattern_changes: Index,
1329    pub max_fill_ratio: Number,
1330    pub min_abs_pivot: Number,
1331    pub max_abs_pivot: Number,
1332    pub last_inertia_positive: Index,
1333    pub last_inertia_negative: Index,
1334    pub last_inertia_zero: Index,
1335    pub last_nnz_a: Index,
1336    pub last_nnz_l: Index,
1337}
1338
1339/// Post-mortem of the KKT linear solver for the most recent solve.
1340///
1341/// Reports what pounce already collects — factorization counts,
1342/// pattern reuse, fill, pivot range, final inertia. Timings are not
1343/// among them: pounce does not instrument the analyse / factor /
1344/// solve phases separately, so there is nothing honest to report and
1345/// the struct omits them rather than inventing zeros.
1346///
1347/// # Safety
1348///
1349/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `stats`,
1350/// when non-NULL, must point at a writable `PounceLinearSolverStats`.
1351#[unsafe(no_mangle)]
1352pub unsafe extern "C" fn GetPounceLinearSolverStats(
1353    ipopt_problem: IpoptProblem,
1354    stats: *mut PounceLinearSolverStats,
1355) -> Bool {
1356    unsafe {
1357        if ipopt_problem.is_null() || stats.is_null() {
1358            return FALSE;
1359        }
1360        let Some(summary) = (*ipopt_problem)
1361            .last_solve
1362            .as_ref()
1363            .and_then(|ls| ls.linear_solver.as_ref())
1364        else {
1365            return FALSE;
1366        };
1367        // Saturate rather than wrap: these are diagnostics, and a
1368        // count that does not fit an `Index` is better reported as the
1369        // largest representable one than as a negative.
1370        let count = |v: u64| Index::try_from(v).unwrap_or(Index::MAX);
1371        let size = |x: usize| Index::try_from(x).unwrap_or(Index::MAX);
1372        let opt_size = |v: Option<usize>| v.map_or(-1, size);
1373        let inertia = summary.last_inertia;
1374        let mut out = PounceLinearSolverStats {
1375            solver_name: [0; 32],
1376            n_factors: count(summary.n_factors),
1377            n_pattern_reuse: count(summary.n_pattern_reuse),
1378            n_pattern_changes: count(summary.n_pattern_changes),
1379            max_fill_ratio: summary.max_fill_ratio.unwrap_or(Number::NAN),
1380            min_abs_pivot: summary.min_abs_pivot.unwrap_or(Number::NAN),
1381            max_abs_pivot: summary.max_abs_pivot.unwrap_or(Number::NAN),
1382            last_inertia_positive: inertia.map_or(-1, |(p, _, _)| size(p)),
1383            last_inertia_negative: inertia.map_or(-1, |(_, n, _)| size(n)),
1384            last_inertia_zero: inertia.map_or(-1, |(_, _, z)| size(z)),
1385            last_nnz_a: opt_size(summary.last_nnz_a),
1386            last_nnz_l: opt_size(summary.last_nnz_l),
1387        };
1388        // Truncate on a byte boundary and always leave room for the
1389        // NUL; backend names are ASCII identifiers well under 31 bytes,
1390        // so this is a guard rather than an expected path.
1391        let name = summary.solver_name.as_bytes();
1392        let keep = name.len().min(out.solver_name.len() - 1);
1393        for (slot, b) in out.solver_name.iter_mut().zip(&name[..keep]) {
1394            *slot = *b as c_char;
1395        }
1396        *stats = out;
1397        TRUE
1398    }
1399}
1400
1401thread_local! {
1402    /// Option registry for handle-free [`GetPounceOptionType`] queries.
1403    ///
1404    /// Built from a throwaway application rather than by re-running the
1405    /// registration functions here, so it cannot drift from the set a
1406    /// real problem carries: it *is* that set, by construction.
1407    static DEFAULT_REGISTRY: Rc<pounce_common::reg_options::RegisteredOptions> =
1408        Rc::clone(IpoptApplication::new().registered_options());
1409}
1410
1411/// Which `AddIpopt*Option` setter a keyword expects.
1412///
1413/// Returns a `PounceOptionType` discriminant: 0 when the keyword is not
1414/// registered in this build, 1 number, 2 integer, 3 string. Lets a
1415/// caller forwarding options from an untyped source pick the setter
1416/// from pounce's registry instead of from the value's own type — the
1417/// difference between `tol: 1` reaching the solver as `1.0` and being
1418/// refused as an integer.
1419///
1420/// `ipopt_problem` may be NULL: option types are a property of the
1421/// build, not of a problem, and a caller deciding how to forward
1422/// options may not have created one yet (code generators, in
1423/// particular, have no handle at all).
1424///
1425/// # Safety
1426///
1427/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `keyword`,
1428/// when non-NULL, must be a NUL-terminated C string.
1429#[unsafe(no_mangle)]
1430pub unsafe extern "C" fn GetPounceOptionType(
1431    ipopt_problem: IpoptProblem,
1432    keyword: *const c_char,
1433) -> c_int {
1434    unsafe {
1435        if keyword.is_null() {
1436            return 0;
1437        }
1438        let Ok(name) = CStr::from_ptr(keyword).to_str() else {
1439            return 0;
1440        };
1441        let registered = if ipopt_problem.is_null() {
1442            DEFAULT_REGISTRY.with(|r| r.get_option(name))
1443        } else {
1444            (*ipopt_problem).app.registered_options().get_option(name)
1445        };
1446        let Some(opt) = registered else {
1447            return 0;
1448        };
1449        match opt.option_type {
1450            OptionType::OT_Number => 1,
1451            OptionType::OT_Integer => 2,
1452            OptionType::OT_String => 3,
1453            OptionType::OT_Unknown => 0,
1454        }
1455    }
1456}
1457
1458// ─────────────────────────────────────────────────────────────
1459// Pounce extension: SQP working-set warm-start C ABI (§7.2 of
1460// `docs/research/active-set-sqp-warm-start.md`).
1461//
1462// Three new entry points; all backward-compatible additions.
1463// No existing signature changes — existing cyipopt / JuMP /
1464// AMPL clients are unaffected.
1465// ─────────────────────────────────────────────────────────────
1466
1467fn bound_status_to_int(s: pounce_qp::BoundStatus) -> c_int {
1468    use pounce_qp::BoundStatus::*;
1469    match s {
1470        Inactive => POUNCE_WS_INACTIVE,
1471        AtLower => POUNCE_WS_AT_LOWER,
1472        AtUpper => POUNCE_WS_AT_UPPER,
1473        Fixed => POUNCE_WS_FIXED_OR_EQ,
1474    }
1475}
1476
1477fn int_to_bound_status(v: c_int) -> Option<pounce_qp::BoundStatus> {
1478    use pounce_qp::BoundStatus::*;
1479    match v {
1480        POUNCE_WS_INACTIVE => Some(Inactive),
1481        POUNCE_WS_AT_LOWER => Some(AtLower),
1482        POUNCE_WS_AT_UPPER => Some(AtUpper),
1483        POUNCE_WS_FIXED_OR_EQ => Some(Fixed),
1484        _ => None,
1485    }
1486}
1487
1488fn cons_status_to_int(s: pounce_qp::ConsStatus) -> c_int {
1489    use pounce_qp::ConsStatus::*;
1490    match s {
1491        Inactive => POUNCE_WS_INACTIVE,
1492        AtLower => POUNCE_WS_AT_LOWER,
1493        AtUpper => POUNCE_WS_AT_UPPER,
1494        Equality => POUNCE_WS_FIXED_OR_EQ,
1495    }
1496}
1497
1498fn int_to_cons_status(v: c_int) -> Option<pounce_qp::ConsStatus> {
1499    use pounce_qp::ConsStatus::*;
1500    match v {
1501        POUNCE_WS_INACTIVE => Some(Inactive),
1502        POUNCE_WS_AT_LOWER => Some(AtLower),
1503        POUNCE_WS_AT_UPPER => Some(AtUpper),
1504        POUNCE_WS_FIXED_OR_EQ => Some(Equality),
1505        _ => None,
1506    }
1507}
1508
1509/// Internal → user row map for the SQP's constraint vector.
1510///
1511/// The SQP works on a *reordered* constraint vector: equalities first,
1512/// inequalities after, each in ascending original index. Its working set
1513/// is in that order, and both C entry points copied it positionally
1514/// against the caller's row indices — so on HS071, whose rows are
1515/// `[x₀x₁x₂x₃ ≥ 25, Σxᵢ² = 40]`, `IpoptGetWorkingSet` reported
1516/// `[Equality, AtLower]`: exactly reversed. A caller feeding that back
1517/// through `IpoptSetWarmStartWorkingSet`, which is the documented
1518/// round-trip, warm-started with the statuses swapped.
1519///
1520/// The split is a pure function of the bounds — a row is an equality iff
1521/// both sides are finite and equal — so the map is reconstructible here,
1522/// with no need to plumb `BoundClassification` out of `pounce-nlp`.
1523/// Mirrors `tnlp_adapter::classify_bounds`, sentinel test included.
1524fn internal_to_user_rows(g_l: &[Number], g_u: &[Number]) -> Vec<usize> {
1525    let m = g_l.len();
1526    let is_eq =
1527        |i: usize| g_l[i] > NLP_LOWER_BOUND_INF && g_u[i] < NLP_UPPER_BOUND_INF && g_l[i] == g_u[i];
1528    let mut map: Vec<usize> = (0..m).filter(|&i| is_eq(i)).collect();
1529    map.extend((0..m).filter(|&i| !is_eq(i)));
1530    map
1531}
1532
1533/// Internal → user variable map for the SQP's bound vector.
1534///
1535/// Fixed variables (`x_l == x_u`) are removed from the internal problem
1536/// altogether, so the working set's bound vector is indexed by *non-fixed*
1537/// position. Same reconstruction argument as `internal_to_user_rows`.
1538fn internal_to_user_vars(x_l: &[Number], x_u: &[Number]) -> Vec<usize> {
1539    (0..x_l.len()).filter(|&i| x_l[i] != x_u[i]).collect()
1540}
1541
1542/// Retrieve the working set produced by the most recent SQP solve
1543/// (`algorithm = active-set-sqp`). Buffer sizes are `n` for
1544/// `bound_status_out` and `m` for `cons_status_out`. Pass `NULL`
1545/// for either to skip that side.
1546///
1547/// Returns `TRUE` (1) on success, `FALSE` (0) if there is no
1548/// working set to retrieve (e.g. no SQP solve has run, the IPM
1549/// path was used, or the very first KKT check declared
1550/// optimality before solving any QP).
1551///
1552/// # Safety
1553///
1554/// `ipopt_problem` must be a valid `IpoptProblem`. Output
1555/// buffers (when non-NULL) must be sized at least `n` and `m`
1556/// respectively.
1557#[unsafe(no_mangle)]
1558pub unsafe extern "C" fn IpoptGetWorkingSet(
1559    ipopt_problem: IpoptProblem,
1560    bound_status_out: *mut IpoptBoundStatus,
1561    cons_status_out: *mut IpoptConsStatus,
1562) -> Bool {
1563    unsafe {
1564        if ipopt_problem.is_null() {
1565            return FALSE;
1566        }
1567        let info = &*ipopt_problem;
1568        let ws = match info.app.last_sqp_working_set() {
1569            Some(w) => w,
1570            None => return FALSE,
1571        };
1572        // Translate out of the SQP's equalities-first ordering, back into
1573        // the caller's row / variable indices.
1574        let row_map = internal_to_user_rows(&info.g_l, &info.g_u);
1575        let var_map = internal_to_user_vars(&info.x_l, &info.x_u);
1576        if ws.constraints.len() != row_map.len() || ws.bounds.len() != var_map.len() {
1577            // The stored set does not match this problem's shape. Report
1578            // nothing rather than something mis-indexed.
1579            return FALSE;
1580        }
1581        if !bound_status_out.is_null() {
1582            // A fixed variable is absent from the internal problem and so
1583            // has no stored status. `Fixed` is what it is.
1584            for i in 0..info.x_l.len() {
1585                *bound_status_out.add(i) = POUNCE_WS_FIXED_OR_EQ;
1586            }
1587            for (internal, &user) in var_map.iter().enumerate() {
1588                *bound_status_out.add(user) = bound_status_to_int(ws.bounds[internal]);
1589            }
1590        }
1591        if !cons_status_out.is_null() {
1592            for (internal, &user) in row_map.iter().enumerate() {
1593                *cons_status_out.add(user) = cons_status_to_int(ws.constraints[internal]);
1594            }
1595        }
1596        TRUE
1597    }
1598}
1599
1600/// Supply a warm-start working set consumed by the next
1601/// [`IpoptSolve`] on this problem. Pass `NULL` for either side to
1602/// cold-start it. The caller-owned buffers are copied; reuse
1603/// across calls is safe.
1604///
1605/// Returns `TRUE` on success, `FALSE` on (a) NULL problem, (b)
1606/// an out-of-range status code in one of the buffers, or
1607/// (c) both inputs NULL (which would equal a no-op
1608/// — call [`IpoptClearWarmStartWorkingSet`] instead).
1609///
1610/// # Safety
1611///
1612/// `ipopt_problem` must be valid. `bound_status_in` (when
1613/// non-NULL) must be sized `n`; `cons_status_in` (when non-NULL)
1614/// must be sized `m`.
1615#[unsafe(no_mangle)]
1616pub unsafe extern "C" fn IpoptSetWarmStartWorkingSet(
1617    ipopt_problem: IpoptProblem,
1618    bound_status_in: *const IpoptBoundStatus,
1619    cons_status_in: *const IpoptConsStatus,
1620) -> Bool {
1621    unsafe {
1622        if ipopt_problem.is_null() {
1623            return FALSE;
1624        }
1625        if bound_status_in.is_null() && cons_status_in.is_null() {
1626            return FALSE;
1627        }
1628        let info = &mut *ipopt_problem;
1629        let n = info.n.max(0) as usize;
1630        let m = info.m.max(0) as usize;
1631        // The caller indexes by *their* rows and variables; the SQP works
1632        // on the reordered, fixed-variables-removed vectors. Translate on
1633        // the way in, mirroring `IpoptGetWorkingSet` on the way out, so
1634        // the documented get/set round-trip is actually a round-trip.
1635        let row_map = internal_to_user_rows(&info.g_l, &info.g_u);
1636        let var_map = internal_to_user_vars(&info.x_l, &info.x_u);
1637        // Validation is not just a range check. A status code says
1638        // where the point sits relative to a bound — but `Fixed` /
1639        // `Equality`, and `AtLower` / `AtUpper` against an infinite
1640        // side, additionally assert something about the *problem*:
1641        // that a variable has `x_l == x_u`, that a row has
1642        // `b_l == b_u`, that the bound being sat on exists at all.
1643        // Those are not guesses about the active set, they are claims
1644        // about the model, and the model is right here to check them
1645        // against.
1646        //
1647        // Accepting them unchecked converted a caller's mistake into a
1648        // silently wrong answer: `POUNCE_WS_FIXED_OR_EQ` on a variable
1649        // whose bounds differ pinned it, the solve over-constrained
1650        // itself, and a *convex* program came back with the wrong
1651        // optimum — the function having returned TRUE. Rejecting is
1652        // strictly better: the caller already handles FALSE, and this
1653        // function already returns it for an out-of-range code, so
1654        // TRUE reasonably reads as "your working set was accepted".
1655        let mut bounds = vec![pounce_qp::BoundStatus::Inactive; var_map.len()];
1656        if !bound_status_in.is_null() {
1657            // Validate every entry the caller supplied, including those for
1658            // fixed variables that the internal problem drops: a wrong
1659            // claim is worth rejecting whether or not it would be used.
1660            for i in 0..n {
1661                let v = *bound_status_in.add(i);
1662                let Some(s) = int_to_bound_status(v) else {
1663                    return FALSE;
1664                };
1665                let lo_finite = info.x_l[i] > NLP_LOWER_BOUND_INF;
1666                let hi_finite = info.x_u[i] < NLP_UPPER_BOUND_INF;
1667                let consistent = match s {
1668                    pounce_qp::BoundStatus::Fixed => info.x_l[i] == info.x_u[i],
1669                    pounce_qp::BoundStatus::AtLower => lo_finite,
1670                    pounce_qp::BoundStatus::AtUpper => hi_finite,
1671                    pounce_qp::BoundStatus::Inactive => true,
1672                };
1673                if !consistent {
1674                    return FALSE;
1675                }
1676            }
1677            for (internal, &user) in var_map.iter().enumerate() {
1678                // Already range-checked above.
1679                if let Some(s) = int_to_bound_status(*bound_status_in.add(user)) {
1680                    bounds[internal] = s;
1681                }
1682            }
1683        }
1684        let mut constraints = vec![pounce_qp::ConsStatus::Inactive; m];
1685        if !cons_status_in.is_null() {
1686            for i in 0..m {
1687                let v = *cons_status_in.add(i);
1688                let Some(s) = int_to_cons_status(v) else {
1689                    return FALSE;
1690                };
1691                let lo_finite = info.g_l[i] > NLP_LOWER_BOUND_INF;
1692                let hi_finite = info.g_u[i] < NLP_UPPER_BOUND_INF;
1693                let consistent = match s {
1694                    pounce_qp::ConsStatus::Equality => {
1695                        lo_finite && hi_finite && info.g_l[i] == info.g_u[i]
1696                    }
1697                    pounce_qp::ConsStatus::AtLower => lo_finite,
1698                    pounce_qp::ConsStatus::AtUpper => hi_finite,
1699                    pounce_qp::ConsStatus::Inactive => true,
1700                };
1701                if !consistent {
1702                    return FALSE;
1703                }
1704            }
1705            for (internal, &user) in row_map.iter().enumerate() {
1706                if let Some(s) = int_to_cons_status(*cons_status_in.add(user)) {
1707                    constraints[internal] = s;
1708                }
1709            }
1710        }
1711        // Stage the working set only. We do *not* know the
1712        // primal/dual iterate here, and we must not invent one:
1713        // `SqpAlgorithm::optimize_with_warm_start` treats a supplied
1714        // `SqpIterates` as *the* starting iterate and never consults
1715        // `get_starting_x` on that branch, so a placeholder `x` would
1716        // silently override the `x` buffer the caller hands to
1717        // `IpoptSolve`. Zeros here restarted every warm solve from
1718        // the origin — outside the bounds on any problem with
1719        // `x_l > 0` — and returned `Infeasible_Problem_Detected` at
1720        // iteration 0 (gh#484). `IpoptSolve` merges this working set
1721        // with the real starting point instead.
1722        info.pending_working_set = Some(pounce_qp::WorkingSet {
1723            bounds,
1724            constraints,
1725        });
1726        TRUE
1727    }
1728}
1729
1730/// Declare which variables enter the problem **nonlinearly** (gh#624).
1731///
1732/// This is the C-API face of Ipopt's `TNLP::get_number_of_nonlinear_variables`
1733/// / `get_list_of_nonlinear_variables` pair, which upstream exposes only
1734/// to C++ callers. It exists so a frontend that already knows the
1735/// structure of its model — CasADi's `pass_nonlinear_variables`, an
1736/// algebraic modeling language, a hand-written driver — can hand that
1737/// knowledge to pounce.
1738///
1739/// Effect is confined to the **limited-memory** Hessian: curvature is
1740/// approximated over the declared subset only, and the Hessian is
1741/// exactly zero for every other variable. Exact-Hessian solves ignore
1742/// the declaration entirely, and so does any solve that never calls
1743/// this function — the default remains "all variables are nonlinear".
1744/// The subset takes precedence over the `num_linear_variables` option,
1745/// matching Ipopt's own ordering.
1746///
1747/// `pos_nonlin_vars` holds `num_nonlin_vars` variable indices **in the
1748/// problem's index style** (the `index_style` passed to
1749/// [`CreateIpoptProblem`]). The subset may be arbitrary and
1750/// noncontiguous; order does not matter. Passing `num_nonlin_vars == n`
1751/// is equivalent to not calling this at all.
1752///
1753/// Returns `FALSE` (leaving any previous declaration untouched) on a
1754/// NULL problem, a negative or oversized count, a NULL array with a
1755/// positive count, or an index outside the problem's variable range.
1756///
1757/// Note the deliberate signature choice: the issue that requested this
1758/// suggested a `const Bool*` mask, but `Bool` in the Ipopt C API is a
1759/// C99 `bool`, and a *array* of those would be a per-element
1760/// data-layout contract that is easy to get wrong from a caller with a
1761/// different boolean width. The count-plus-index-list shape is the one
1762/// the TNLP callbacks already use.
1763///
1764/// # Safety
1765///
1766/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1767/// `pos_nonlin_vars`, when non-NULL, must point at `num_nonlin_vars`
1768/// readable `ipindex` values.
1769#[unsafe(no_mangle)]
1770pub unsafe extern "C" fn IpoptSetNonlinearVariables(
1771    ipopt_problem: IpoptProblem,
1772    num_nonlin_vars: Index,
1773    pos_nonlin_vars: *const Index,
1774) -> Bool {
1775    unsafe {
1776        if ipopt_problem.is_null() {
1777            return FALSE;
1778        }
1779        let info = &mut *ipopt_problem;
1780        if num_nonlin_vars < 0 || num_nonlin_vars > info.n {
1781            return FALSE;
1782        }
1783        if num_nonlin_vars > 0 && pos_nonlin_vars.is_null() {
1784            return FALSE;
1785        }
1786        let offset = if info.index_style == 1 { 1 } else { 0 };
1787        let raw = if num_nonlin_vars == 0 {
1788            &[][..]
1789        } else {
1790            std::slice::from_raw_parts(pos_nonlin_vars, num_nonlin_vars as usize)
1791        };
1792        // Validate before storing: a half-applied declaration would be
1793        // worse than a refused one.
1794        for &p in raw {
1795            let zero_based = p - offset;
1796            if zero_based < 0 || zero_based >= info.n {
1797                return FALSE;
1798            }
1799        }
1800        info.nonlinear_vars = Some(raw.to_vec());
1801        TRUE
1802    }
1803}
1804
1805/// Drop a subset declared by [`IpoptSetNonlinearVariables`], restoring
1806/// the default (every variable treated as nonlinear).
1807///
1808/// # Safety
1809///
1810/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1811#[unsafe(no_mangle)]
1812pub unsafe extern "C" fn IpoptClearNonlinearVariables(ipopt_problem: IpoptProblem) -> Bool {
1813    unsafe {
1814        if ipopt_problem.is_null() {
1815            return FALSE;
1816        }
1817        (*ipopt_problem).nonlinear_vars = None;
1818        TRUE
1819    }
1820}
1821
1822/// Drop any pending warm-start working set without solving. The
1823/// next [`IpoptSolve`] will cold-start.
1824///
1825/// # Safety
1826///
1827/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1828#[unsafe(no_mangle)]
1829pub unsafe extern "C" fn IpoptClearWarmStartWorkingSet(ipopt_problem: IpoptProblem) -> Bool {
1830    unsafe {
1831        if ipopt_problem.is_null() {
1832            return FALSE;
1833        }
1834        (*ipopt_problem).pending_working_set = None;
1835        (*ipopt_problem).app.clear_sqp_warm_start();
1836        TRUE
1837    }
1838}
1839
1840/// Convenience one-shot: equivalent to
1841/// `IpoptSetWarmStartWorkingSet` + `IpoptSolve` +
1842/// `IpoptGetWorkingSet` in sequence. The input/output working-set
1843/// buffers are independent (so a caller can read back the new
1844/// working set into the same array used as input). Pass `NULL`
1845/// for any in/out buffer to skip that side.
1846///
1847/// Returns the `ApplicationReturnStatus` integer, identical to
1848/// [`IpoptSolve`].
1849///
1850/// # Safety
1851///
1852/// All pointer arguments follow the same contract as
1853/// `IpoptSolve` plus the working-set buffer sizes documented on
1854/// `IpoptSetWarmStartWorkingSet` / `IpoptGetWorkingSet`.
1855#[allow(clippy::too_many_arguments)]
1856#[unsafe(no_mangle)]
1857pub unsafe extern "C" fn IpoptSolveWarmStart(
1858    ipopt_problem: IpoptProblem,
1859    x: *mut Number,
1860    g: *mut Number,
1861    obj_val: *mut Number,
1862    mult_g: *mut Number,
1863    mult_x_L: *mut Number,
1864    mult_x_U: *mut Number,
1865    bound_status_in: *const IpoptBoundStatus,
1866    cons_status_in: *const IpoptConsStatus,
1867    bound_status_out: *mut IpoptBoundStatus,
1868    cons_status_out: *mut IpoptConsStatus,
1869    user_data: *mut c_void,
1870) -> Index {
1871    if ipopt_problem.is_null() {
1872        return ApplicationReturnStatus::InternalError as Index;
1873    }
1874    // Guard the working-set set/get helpers too. The inner `IpoptSolve` is
1875    // independently guarded, but a panic in the warm-start working-set
1876    // marshalling would otherwise still abort across `extern "C"`.
1877    ffi_guard(ApplicationReturnStatus::InternalError as Index, || unsafe {
1878        // Best-effort set. Errors here (e.g. bad status code) are
1879        // silently treated as cold-start; the caller can probe via
1880        // `IpoptSetWarmStartWorkingSet` directly if they need to
1881        // validate the input.
1882        if !bound_status_in.is_null() || !cons_status_in.is_null() {
1883            let _ = IpoptSetWarmStartWorkingSet(ipopt_problem, bound_status_in, cons_status_in);
1884        }
1885        let status = IpoptSolve(
1886            ipopt_problem,
1887            x,
1888            g,
1889            obj_val,
1890            mult_g,
1891            mult_x_L,
1892            mult_x_U,
1893            user_data,
1894        );
1895        let _ = IpoptGetWorkingSet(ipopt_problem, bound_status_out, cons_status_out);
1896        status
1897    })
1898}
1899
1900/// Adapter that bridges the user-supplied C callback table to the
1901/// in-crate [`TNLP`] trait. Mirrors `Interfaces/IpStdInterfaceTNLP.cpp`
1902/// (`StdInterfaceTNLP`); each TNLP method forwards to the matching
1903/// `Eval_*_CB` and propagates `false` returns up so the algorithm
1904/// layer can map them to `Invalid_Number_Detected`.
1905///
1906/// Holds a snapshot of bounds and the initial `x`. After `optimize_tnlp`
1907/// finishes, `finalize_solution` is called by the algorithm layer; the
1908/// adapter records the final iterate in `final_*` fields, which the
1909/// outer [`IpoptSolve`] copies back into the caller's buffers.
1910pub(crate) struct CCallbackTnlp {
1911    pub(crate) n: Index,
1912    pub(crate) m: Index,
1913    pub(crate) nele_jac: Index,
1914    pub(crate) nele_hess: Index,
1915    pub(crate) index_style: Index,
1916    pub(crate) x_l: Vec<Number>,
1917    pub(crate) x_u: Vec<Number>,
1918    pub(crate) g_l: Vec<Number>,
1919    pub(crate) g_u: Vec<Number>,
1920    pub(crate) initial_x: Vec<Number>,
1921    pub(crate) eval_f: Option<Eval_F_CB>,
1922    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
1923    pub(crate) eval_g: Option<Eval_G_CB>,
1924    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
1925    pub(crate) eval_h: Option<Eval_H_CB>,
1926    pub(crate) user_data: *mut c_void,
1927    /// User-installed intermediate callback, copied at solve time so the
1928    /// TNLP-trait `intermediate_callback` impl can forward through to it.
1929    pub(crate) intermediate_cb: Option<Intermediate_CB>,
1930    /// Snapshot of user-provided scaling captured at solve time.
1931    pub(crate) user_scaling: Option<UserScaling>,
1932    /// Snapshot of the nonlinear-variable subset (gh#624), in the
1933    /// problem's index style.
1934    pub(crate) nonlinear_vars: Option<Vec<Index>>,
1935    pub(crate) final_status: Option<pounce_nlp::alg_types::SolverReturn>,
1936    pub(crate) final_x: Vec<Number>,
1937    pub(crate) final_z_l: Vec<Number>,
1938    pub(crate) final_z_u: Vec<Number>,
1939    pub(crate) final_g: Vec<Number>,
1940    pub(crate) final_lambda: Vec<Number>,
1941    pub(crate) final_obj: Number,
1942}
1943
1944impl TNLP for CCallbackTnlp {
1945    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1946        Some(NlpInfo {
1947            n: self.n as pounce_common::types::Index,
1948            m: self.m as pounce_common::types::Index,
1949            nnz_jac_g: self.nele_jac as pounce_common::types::Index,
1950            nnz_h_lag: self.nele_hess as pounce_common::types::Index,
1951            index_style: if self.index_style == 1 {
1952                IndexStyle::Fortran
1953            } else {
1954                IndexStyle::C
1955            },
1956        })
1957    }
1958
1959    /// gh#624 — serve the subset staged by
1960    /// [`IpoptSetNonlinearVariables`]. `-1` (no subset) keeps the
1961    /// upstream default of "all variables are nonlinear".
1962    fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
1963        match &self.nonlinear_vars {
1964            Some(v) => v.len() as pounce_common::types::Index,
1965            None => -1,
1966        }
1967    }
1968
1969    fn get_list_of_nonlinear_variables(
1970        &mut self,
1971        pos_nonlin_vars: &mut [pounce_common::types::Index],
1972    ) -> bool {
1973        let Some(v) = self.nonlinear_vars.as_ref() else {
1974            return false;
1975        };
1976        if v.len() != pos_nonlin_vars.len() {
1977            return false;
1978        }
1979        pos_nonlin_vars.copy_from_slice(v);
1980        true
1981    }
1982
1983    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1984        if !self.x_l.is_empty() {
1985            b.x_l.copy_from_slice(&self.x_l);
1986        }
1987        if !self.x_u.is_empty() {
1988            b.x_u.copy_from_slice(&self.x_u);
1989        }
1990        if !self.g_l.is_empty() {
1991            b.g_l.copy_from_slice(&self.g_l);
1992        }
1993        if !self.g_u.is_empty() {
1994            b.g_u.copy_from_slice(&self.g_u);
1995        }
1996        true
1997    }
1998
1999    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2000        if !self.initial_x.is_empty() {
2001            sp.x.copy_from_slice(&self.initial_x);
2002        }
2003        true
2004    }
2005
2006    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
2007        let Some(s) = self.user_scaling.as_ref() else {
2008            return false;
2009        };
2010        *req.obj_scaling = s.obj_scaling;
2011        if let Some(x) = s.x_scaling.as_ref() {
2012            if x.len() == req.x_scaling.len() {
2013                req.x_scaling.copy_from_slice(x);
2014                *req.use_x_scaling = true;
2015            }
2016        } else {
2017            *req.use_x_scaling = false;
2018        }
2019        if let Some(g) = s.g_scaling.as_ref() {
2020            if g.len() == req.g_scaling.len() {
2021                req.g_scaling.copy_from_slice(g);
2022                *req.use_g_scaling = true;
2023            }
2024        } else {
2025            *req.use_g_scaling = false;
2026        }
2027        true
2028    }
2029
2030    fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
2031        let cb = self.eval_f?;
2032        let mut obj = 0.0;
2033        let ok = unsafe {
2034            cb(
2035                self.n,
2036                x.as_ptr() as *mut Number,
2037                if new_x { TRUE } else { FALSE },
2038                &mut obj,
2039                self.user_data,
2040            )
2041        };
2042        if ok != FALSE { Some(obj) } else { None }
2043    }
2044
2045    fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
2046        let Some(cb) = self.eval_grad_f else {
2047            return false;
2048        };
2049        let ok = unsafe {
2050            cb(
2051                self.n,
2052                x.as_ptr() as *mut Number,
2053                if new_x { TRUE } else { FALSE },
2054                grad_f.as_mut_ptr(),
2055                self.user_data,
2056            )
2057        };
2058        ok != FALSE
2059    }
2060
2061    fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2062        if self.m == 0 {
2063            return true;
2064        }
2065        let Some(cb) = self.eval_g else {
2066            return false;
2067        };
2068        let ok = unsafe {
2069            cb(
2070                self.n,
2071                x.as_ptr() as *mut Number,
2072                if new_x { TRUE } else { FALSE },
2073                self.m,
2074                g.as_mut_ptr(),
2075                self.user_data,
2076            )
2077        };
2078        ok != FALSE
2079    }
2080
2081    fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
2082        if self.m == 0 || self.nele_jac == 0 {
2083            return true;
2084        }
2085        let Some(cb) = self.eval_jac_g else {
2086            return false;
2087        };
2088        let x_ptr = x
2089            .map(|s| s.as_ptr() as *mut Number)
2090            .unwrap_or(std::ptr::null_mut());
2091        let ok = match mode {
2092            SparsityRequest::Structure { irow, jcol } => unsafe {
2093                cb(
2094                    self.n,
2095                    x_ptr,
2096                    if new_x { TRUE } else { FALSE },
2097                    self.m,
2098                    self.nele_jac,
2099                    irow.as_mut_ptr(),
2100                    jcol.as_mut_ptr(),
2101                    std::ptr::null_mut(),
2102                    self.user_data,
2103                )
2104            },
2105            SparsityRequest::Values { values } => unsafe {
2106                cb(
2107                    self.n,
2108                    x_ptr,
2109                    if new_x { TRUE } else { FALSE },
2110                    self.m,
2111                    self.nele_jac,
2112                    std::ptr::null_mut(),
2113                    std::ptr::null_mut(),
2114                    values.as_mut_ptr(),
2115                    self.user_data,
2116                )
2117            },
2118        };
2119        ok != FALSE
2120    }
2121
2122    fn eval_h(
2123        &mut self,
2124        x: Option<&[Number]>,
2125        new_x: bool,
2126        obj_factor: Number,
2127        lambda: Option<&[Number]>,
2128        new_lambda: bool,
2129        mode: SparsityRequest<'_>,
2130    ) -> bool {
2131        let Some(cb) = self.eval_h else {
2132            return false;
2133        };
2134        if self.nele_hess == 0 {
2135            return true;
2136        }
2137        let x_ptr = x
2138            .map(|s| s.as_ptr() as *mut Number)
2139            .unwrap_or(std::ptr::null_mut());
2140        let lambda_ptr = lambda
2141            .map(|s| s.as_ptr() as *mut Number)
2142            .unwrap_or(std::ptr::null_mut());
2143        let ok = match mode {
2144            SparsityRequest::Structure { irow, jcol } => unsafe {
2145                cb(
2146                    self.n,
2147                    x_ptr,
2148                    if new_x { TRUE } else { FALSE },
2149                    obj_factor,
2150                    self.m,
2151                    lambda_ptr,
2152                    if new_lambda { TRUE } else { FALSE },
2153                    self.nele_hess,
2154                    irow.as_mut_ptr(),
2155                    jcol.as_mut_ptr(),
2156                    std::ptr::null_mut(),
2157                    self.user_data,
2158                )
2159            },
2160            SparsityRequest::Values { values } => unsafe {
2161                cb(
2162                    self.n,
2163                    x_ptr,
2164                    if new_x { TRUE } else { FALSE },
2165                    obj_factor,
2166                    self.m,
2167                    lambda_ptr,
2168                    if new_lambda { TRUE } else { FALSE },
2169                    self.nele_hess,
2170                    std::ptr::null_mut(),
2171                    std::ptr::null_mut(),
2172                    values.as_mut_ptr(),
2173                    self.user_data,
2174                )
2175            },
2176        };
2177        ok != FALSE
2178    }
2179
2180    fn intermediate_callback(
2181        &mut self,
2182        stats: pounce_nlp::tnlp::IterStats,
2183        _ip_data: &IpoptData,
2184        _ip_cq: &IpoptCq,
2185    ) -> bool {
2186        let Some(cb) = self.intermediate_cb else {
2187            return true;
2188        };
2189        let ok = unsafe {
2190            cb(
2191                stats.mode as Index,
2192                stats.iter as Index,
2193                stats.obj_value,
2194                stats.inf_pr,
2195                stats.inf_du,
2196                stats.mu,
2197                stats.d_norm,
2198                stats.regularization_size,
2199                stats.alpha_du,
2200                stats.alpha_pr,
2201                stats.ls_trials as Index,
2202                self.user_data,
2203            )
2204        };
2205        ok != FALSE
2206    }
2207
2208    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
2209        self.final_status = Some(sol.status);
2210        if !sol.x.is_empty() {
2211            self.final_x.copy_from_slice(sol.x);
2212        }
2213        if !sol.z_l.is_empty() {
2214            self.final_z_l.copy_from_slice(sol.z_l);
2215        }
2216        if !sol.z_u.is_empty() {
2217            self.final_z_u.copy_from_slice(sol.z_u);
2218        }
2219        if !sol.g.is_empty() {
2220            self.final_g.copy_from_slice(sol.g);
2221        }
2222        if !sol.lambda.is_empty() {
2223            self.final_lambda.copy_from_slice(sol.lambda);
2224        }
2225        self.final_obj = sol.obj_value;
2226    }
2227}
2228
2229/// Enable per-iteration history capture on the underlying
2230/// `IpoptApplication`. Must be called *before* [`IpoptSolve`] for the
2231/// trajectory to appear in the report written by
2232/// [`IpoptWriteSolveReport`]. Off by default — capturing each iterate
2233/// has a small per-iter cost the IPM core skips otherwise.
2234///
2235/// Returns `TRUE` on success, `FALSE` if `ipopt_problem` is NULL.
2236///
2237/// # Safety
2238///
2239/// `ipopt_problem` must be a valid handle returned by
2240/// [`CreateIpoptProblem`] (or `NULL`).
2241#[unsafe(no_mangle)]
2242pub unsafe extern "C" fn IpoptEnableIterHistory(ipopt_problem: IpoptProblem) -> Bool {
2243    if ipopt_problem.is_null() {
2244        return FALSE;
2245    }
2246    let info = unsafe { &mut *ipopt_problem };
2247    info.app.enable_iter_history();
2248    TRUE
2249}
2250
2251/// Write a `pounce.solve-report/v1` JSON file capturing the most
2252/// recent [`IpoptSolve`] result. `path` is a NUL-terminated UTF-8
2253/// filesystem path. `detail` is one of `"summary"` or `"full"`
2254/// (NUL-terminated); pass `NULL` for the default (`"summary"`).
2255///
2256/// When `detail = "full"` and [`IpoptEnableIterHistory`] was called
2257/// pre-solve, the per-iteration trajectory is embedded so that
2258/// downstream tools (`diagnose`, `find_stalls`, `convergence_trace`)
2259/// see the same trace the `pounce` CLI's `--json-output` path
2260/// produces. The input descriptor is recorded as `tnlp-direct`
2261/// because the cinterface receives callbacks rather than a file.
2262///
2263/// Returns `TRUE` on a successful write, `FALSE` for NULL handle,
2264/// no prior solve, an invalid `detail`, a bad path, or an I/O error.
2265///
2266/// # Safety
2267///
2268/// `ipopt_problem` must be a valid handle; `path` must be a valid
2269/// NUL-terminated UTF-8 string; `detail` must be NULL or a valid
2270/// NUL-terminated UTF-8 string.
2271#[unsafe(no_mangle)]
2272pub unsafe extern "C" fn IpoptWriteSolveReport(
2273    ipopt_problem: IpoptProblem,
2274    path: *const c_char,
2275    detail: *const c_char,
2276) -> Bool {
2277    use pounce_solve_report::{
2278        InputDescriptor, ReportBuilder, ReportDetail, status_to_solve_result_num, write_report_file,
2279    };
2280
2281    // Guard the report build/write: it clones the retained iterate and runs
2282    // the `pounce-solve-report` serializer + file I/O, any of which could
2283    // panic on an unexpected state. A panic unwinding across `extern "C"`
2284    // aborts the embedding process; report `FALSE` instead. (See `ffi_guard`.)
2285    ffi_guard(FALSE, || unsafe {
2286        if ipopt_problem.is_null() || path.is_null() {
2287            return FALSE;
2288        }
2289        let info = &*ipopt_problem;
2290        let Some(last) = info.last_solve.as_ref() else {
2291            return FALSE;
2292        };
2293
2294        let Ok(path_str) = CStr::from_ptr(path).to_str() else {
2295            return FALSE;
2296        };
2297
2298        let detail_choice = if detail.is_null() {
2299            ReportDetail::Summary
2300        } else {
2301            let Ok(detail_str) = CStr::from_ptr(detail).to_str() else {
2302                return FALSE;
2303            };
2304            match ReportDetail::parse(detail_str) {
2305                Ok(d) => d,
2306                Err(_) => return FALSE,
2307            }
2308        };
2309
2310        let mut builder = ReportBuilder::new(detail_choice, InputDescriptor::TnlpDirect);
2311        builder.problem.n_variables = info.n;
2312        builder.problem.n_constraints = info.m;
2313        builder.problem.n_objectives = 1;
2314        builder.problem.nnz_jac_g = Some(info.nele_jac);
2315        builder.problem.nnz_h_lag = Some(info.nele_hess);
2316
2317        builder.solution.status = last.status;
2318        builder.solution.solve_result_num = status_to_solve_result_num(last.status);
2319        builder.solution.objective = last.final_obj;
2320        builder.solution.x = last.final_x.clone();
2321        builder.solution.lambda = last.final_lambda.clone();
2322
2323        builder.ingest_stats(&last.stats);
2324        if let Some(linsol) = last.linear_solver.clone() {
2325            builder.set_linear_solver_summary(linsol);
2326        }
2327
2328        let report = builder.finish();
2329        match write_report_file(std::path::Path::new(path_str), &report) {
2330            Ok(_) => TRUE,
2331            Err(_) => FALSE,
2332        }
2333    })
2334}
2335
2336#[cfg(test)]
2337mod tests {
2338    use super::*;
2339    use std::ffi::CString;
2340
2341    unsafe extern "C" fn dummy_eval_f(
2342        _n: Index,
2343        _x: *const Number,
2344        _new_x: Bool,
2345        _obj_value: *mut Number,
2346        _user_data: *mut c_void,
2347    ) -> Bool {
2348        TRUE
2349    }
2350    unsafe extern "C" fn dummy_eval_grad_f(
2351        _n: Index,
2352        _x: *const Number,
2353        _new_x: Bool,
2354        _grad_f: *mut Number,
2355        _user_data: *mut c_void,
2356    ) -> Bool {
2357        TRUE
2358    }
2359
2360    fn create_unconstrained() -> IpoptProblem {
2361        let xl = [-1.0; 4];
2362        let xu = [1.0; 4];
2363        unsafe {
2364            CreateIpoptProblem(
2365                4,
2366                xl.as_ptr(),
2367                xu.as_ptr(),
2368                0,
2369                std::ptr::null(),
2370                std::ptr::null(),
2371                0,
2372                10,
2373                0,
2374                Some(dummy_eval_f),
2375                None,
2376                Some(dummy_eval_grad_f),
2377                None,
2378                None,
2379            )
2380        }
2381    }
2382
2383    #[test]
2384    fn create_succeeds_for_unconstrained_problem() {
2385        let p = create_unconstrained();
2386        assert!(!p.is_null());
2387        unsafe { FreeIpoptProblem(p) };
2388    }
2389
2390    #[test]
2391    fn create_returns_null_on_missing_required_callbacks() {
2392        let xl = [-1.0; 4];
2393        let xu = [1.0; 4];
2394        let p = unsafe {
2395            CreateIpoptProblem(
2396                4,
2397                xl.as_ptr(),
2398                xu.as_ptr(),
2399                0,
2400                std::ptr::null(),
2401                std::ptr::null(),
2402                0,
2403                10,
2404                0,
2405                None, // missing eval_f
2406                None,
2407                Some(dummy_eval_grad_f),
2408                None,
2409                None,
2410            )
2411        };
2412        assert!(p.is_null());
2413    }
2414
2415    #[test]
2416    fn create_returns_null_on_negative_n() {
2417        let p = unsafe {
2418            CreateIpoptProblem(
2419                -1,
2420                std::ptr::null(),
2421                std::ptr::null(),
2422                0,
2423                std::ptr::null(),
2424                std::ptr::null(),
2425                0,
2426                10,
2427                0,
2428                Some(dummy_eval_f),
2429                None,
2430                Some(dummy_eval_grad_f),
2431                None,
2432                None,
2433            )
2434        };
2435        assert!(p.is_null());
2436    }
2437
2438    #[test]
2439    fn create_returns_null_on_invalid_index_style() {
2440        let xl = [0.0; 1];
2441        let xu = [1.0; 1];
2442        let p = unsafe {
2443            CreateIpoptProblem(
2444                1,
2445                xl.as_ptr(),
2446                xu.as_ptr(),
2447                0,
2448                std::ptr::null(),
2449                std::ptr::null(),
2450                0,
2451                1,
2452                2, // valid values are 0 and 1
2453                Some(dummy_eval_f),
2454                None,
2455                Some(dummy_eval_grad_f),
2456                None,
2457                None,
2458            )
2459        };
2460        assert!(p.is_null());
2461    }
2462
2463    #[test]
2464    fn add_int_option_forwards_to_application() {
2465        let p = create_unconstrained();
2466        let key = CString::new("print_level").unwrap();
2467        let ok = unsafe { AddIpoptIntOption(p, key.as_ptr(), 5) };
2468        assert_eq!(ok, TRUE);
2469        let info = unsafe { &*p };
2470        let (level, found) = info
2471            .app
2472            .options()
2473            .get_integer_value("print_level", "")
2474            .unwrap();
2475        assert!(found);
2476        assert_eq!(level, 5);
2477        unsafe { FreeIpoptProblem(p) };
2478    }
2479
2480    #[test]
2481    fn add_str_option_with_invalid_key_returns_false() {
2482        let p = create_unconstrained();
2483        let key = CString::new("totally_unknown_option").unwrap();
2484        let val = CString::new("yes").unwrap();
2485        let ok = unsafe { AddIpoptStrOption(p, key.as_ptr(), val.as_ptr()) };
2486        assert_eq!(ok, FALSE);
2487        unsafe { FreeIpoptProblem(p) };
2488    }
2489
2490    #[test]
2491    fn add_options_on_null_problem_returns_false() {
2492        let key = CString::new("print_level").unwrap();
2493        let v = CString::new("yes").unwrap();
2494        unsafe {
2495            assert_eq!(
2496                AddIpoptIntOption(std::ptr::null_mut(), key.as_ptr(), 5),
2497                FALSE
2498            );
2499            assert_eq!(
2500                AddIpoptNumOption(std::ptr::null_mut(), key.as_ptr(), 1.0),
2501                FALSE
2502            );
2503            assert_eq!(
2504                AddIpoptStrOption(std::ptr::null_mut(), key.as_ptr(), v.as_ptr()),
2505                FALSE
2506            );
2507        }
2508    }
2509
2510    unsafe extern "C" fn dummy_intermediate(
2511        _alg_mod: Index,
2512        _iter_count: Index,
2513        _obj_value: Number,
2514        _inf_pr: Number,
2515        _inf_du: Number,
2516        _mu: Number,
2517        _d_norm: Number,
2518        _regularization_size: Number,
2519        _alpha_du: Number,
2520        _alpha_pr: Number,
2521        _ls_trials: Index,
2522        _user_data: *mut c_void,
2523    ) -> Bool {
2524        TRUE
2525    }
2526
2527    #[test]
2528    fn set_intermediate_callback_stores_pointer() {
2529        let p = create_unconstrained();
2530        let ok = unsafe { SetIntermediateCallback(p, Some(dummy_intermediate)) };
2531        assert_eq!(ok, TRUE);
2532        let info = unsafe { &*p };
2533        assert!(info.intermediate_cb.is_some());
2534        unsafe { FreeIpoptProblem(p) };
2535    }
2536
2537    #[test]
2538    fn solve_returns_internal_error_on_null_problem() {
2539        let rc = unsafe {
2540            IpoptSolve(
2541                std::ptr::null_mut(),
2542                std::ptr::null_mut(),
2543                std::ptr::null_mut(),
2544                std::ptr::null_mut(),
2545                std::ptr::null_mut(),
2546                std::ptr::null_mut(),
2547                std::ptr::null_mut(),
2548                std::ptr::null_mut(),
2549            )
2550        };
2551        assert_eq!(rc, -199);
2552    }
2553
2554    #[test]
2555    fn free_null_is_safe() {
2556        unsafe { FreeIpoptProblem(std::ptr::null_mut()) };
2557    }
2558
2559    // ---- End-to-end bridge: 1-D unconstrained quadratic ----
2560    //
2561    // f(x) = (x - 2)^2, no bounds, no constraints. Newton driver
2562    // converges in one step.
2563
2564    unsafe extern "C" fn quad_eval_f(
2565        _n: Index,
2566        x: *const Number,
2567        _new_x: Bool,
2568        obj_value: *mut Number,
2569        _user_data: *mut c_void,
2570    ) -> Bool {
2571        unsafe {
2572            let v = *x.offset(0);
2573            *obj_value = (v - 2.0) * (v - 2.0);
2574            TRUE
2575        }
2576    }
2577    unsafe extern "C" fn quad_eval_grad_f(
2578        _n: Index,
2579        x: *const Number,
2580        _new_x: Bool,
2581        grad: *mut Number,
2582        _user_data: *mut c_void,
2583    ) -> Bool {
2584        unsafe {
2585            let v = *x.offset(0);
2586            *grad.offset(0) = 2.0 * (v - 2.0);
2587            TRUE
2588        }
2589    }
2590    unsafe extern "C" fn quad_eval_h(
2591        _n: Index,
2592        _x: *const Number,
2593        _new_x: Bool,
2594        obj_factor: Number,
2595        _m: Index,
2596        _lambda: *const Number,
2597        _new_lambda: Bool,
2598        _nele_hess: Index,
2599        irow: *mut Index,
2600        jcol: *mut Index,
2601        values: *mut Number,
2602        _user_data: *mut c_void,
2603    ) -> Bool {
2604        unsafe {
2605            if !irow.is_null() && !jcol.is_null() && values.is_null() {
2606                *irow.offset(0) = 0;
2607                *jcol.offset(0) = 0;
2608            } else if irow.is_null() && jcol.is_null() && !values.is_null() {
2609                *values.offset(0) = 2.0 * obj_factor;
2610            } else {
2611                return FALSE;
2612            }
2613            TRUE
2614        }
2615    }
2616
2617    #[test]
2618    fn solve_drives_unconstrained_quadratic_through_bridge() {
2619        // Bounds wide open (kappa1 push won't move us off 0.0 since
2620        // |0| < 1e19, but the Newton step lands us at 2.0 anyway).
2621        let xl = [-1.0e20];
2622        let xu = [1.0e20];
2623        let p = unsafe {
2624            CreateIpoptProblem(
2625                1,
2626                xl.as_ptr(),
2627                xu.as_ptr(),
2628                0,
2629                std::ptr::null(),
2630                std::ptr::null(),
2631                0,
2632                1,
2633                0,
2634                Some(quad_eval_f),
2635                None,
2636                Some(quad_eval_grad_f),
2637                None,
2638                Some(quad_eval_h),
2639            )
2640        };
2641        assert!(!p.is_null());
2642        let mut x = [0.0_f64];
2643        let mut obj = 0.0_f64;
2644        let rc = unsafe {
2645            IpoptSolve(
2646                p,
2647                x.as_mut_ptr(),
2648                std::ptr::null_mut(),
2649                &mut obj,
2650                std::ptr::null_mut(),
2651                std::ptr::null_mut(),
2652                std::ptr::null_mut(),
2653                std::ptr::null_mut(),
2654            )
2655        };
2656        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
2657        assert!((x[0] - 2.0).abs() < 1e-6, "x[0] = {}", x[0]);
2658        assert!(obj.abs() < 1e-10, "obj = {}", obj);
2659        unsafe { FreeIpoptProblem(p) };
2660    }
2661
2662    /// F5: `IpoptSolve` invalidates the retained `last_solve` stats **up
2663    /// front**, so a solve that bails — or whose pounce-internal panic
2664    /// `ffi_guard` catches (returning `Internal_Error`) — does not leave the
2665    /// post-solve accessors (`GetIpoptIterCount`, `IpoptWriteSolveReport`, …)
2666    /// silently reporting the *previous* solve's stats.
2667    ///
2668    /// A caught panic can't be injected deterministically through the public
2669    /// C ABI (a panic in a user `extern "C"` callback aborts at its own
2670    /// boundary; see `ffi_guard`). We drive the equivalent control-flow shape:
2671    /// after a successful solve we corrupt `n` to a negative value so the next
2672    /// `IpoptSolve` returns `InvalidProblemDefinition` from inside the guarded
2673    /// body **without** reaching the trailing `last_solve = Some(..)` write —
2674    /// exactly where a caught panic also bails. The up-front clear makes the
2675    /// accessor report "no data" (0) in both cases rather than stale data.
2676    #[test]
2677    fn stale_stats_cleared_when_resolve_bails() {
2678        let xl = [-1.0e20];
2679        let xu = [1.0e20];
2680        let p = unsafe {
2681            CreateIpoptProblem(
2682                1,
2683                xl.as_ptr(),
2684                xu.as_ptr(),
2685                0,
2686                std::ptr::null(),
2687                std::ptr::null(),
2688                0,
2689                1,
2690                0,
2691                Some(quad_eval_f),
2692                None,
2693                Some(quad_eval_grad_f),
2694                None,
2695                Some(quad_eval_h),
2696            )
2697        };
2698        assert!(!p.is_null());
2699
2700        let mut x = [0.0_f64];
2701        let mut obj = 0.0_f64;
2702        let rc = unsafe {
2703            IpoptSolve(
2704                p,
2705                x.as_mut_ptr(),
2706                std::ptr::null_mut(),
2707                &mut obj,
2708                std::ptr::null_mut(),
2709                std::ptr::null_mut(),
2710                std::ptr::null_mut(),
2711                std::ptr::null_mut(),
2712            )
2713        };
2714        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
2715        // The successful solve recorded real stats.
2716        let iters_after_success = unsafe { GetIpoptIterCount(p) };
2717        assert!(
2718            iters_after_success >= 1,
2719            "a converged solve should record >=1 iteration, got {iters_after_success}"
2720        );
2721        assert!(unsafe { (*p).last_solve.is_some() });
2722
2723        // Corrupt the problem so the next solve bails early in the guarded body
2724        // (the same place a caught panic would land) without recording stats.
2725        unsafe { (*p).n = -1 };
2726        let mut x2 = [0.0_f64];
2727        let rc2 = unsafe {
2728            IpoptSolve(
2729                p,
2730                x2.as_mut_ptr(),
2731                std::ptr::null_mut(),
2732                std::ptr::null_mut(),
2733                std::ptr::null_mut(),
2734                std::ptr::null_mut(),
2735                std::ptr::null_mut(),
2736                std::ptr::null_mut(),
2737            )
2738        };
2739        assert_eq!(
2740            rc2,
2741            ApplicationReturnStatus::InvalidProblemDefinition as Index
2742        );
2743
2744        // Post-fix: the up-front invalidation cleared the retained stats, so
2745        // the accessor reports "no data" (0), not the previous iteration count.
2746        // Pre-fix this returned `iters_after_success` (stale).
2747        assert!(
2748            unsafe { (*p).last_solve.is_none() },
2749            "a bailed re-solve must clear stale last_solve (F5)"
2750        );
2751        assert_eq!(
2752            unsafe { GetIpoptIterCount(p) },
2753            0,
2754            "stale iteration count must not survive a bailed re-solve (F5)"
2755        );
2756
2757        unsafe { FreeIpoptProblem(p) };
2758    }
2759
2760    #[test]
2761    fn solve_invalid_problem_definition_when_x_null() {
2762        let p = create_unconstrained();
2763        let rc = unsafe {
2764            IpoptSolve(
2765                p,
2766                std::ptr::null_mut(), // x null but n > 0
2767                std::ptr::null_mut(),
2768                std::ptr::null_mut(),
2769                std::ptr::null_mut(),
2770                std::ptr::null_mut(),
2771                std::ptr::null_mut(),
2772                std::ptr::null_mut(),
2773            )
2774        };
2775        assert_eq!(
2776            rc,
2777            ApplicationReturnStatus::InvalidProblemDefinition as Index
2778        );
2779        unsafe { FreeIpoptProblem(p) };
2780    }
2781
2782    // ---- New entry points (issue #19) ----
2783
2784    #[test]
2785    fn get_version_writes_pkg_version() {
2786        let (mut mj, mut mn, mut pt) = (-1, -1, -1);
2787        unsafe { GetIpoptVersion(&mut mj, &mut mn, &mut pt) };
2788        let expected = parse_pkg_version(env!("CARGO_PKG_VERSION"));
2789        assert_eq!((mj, mn, pt), expected);
2790    }
2791
2792    #[test]
2793    fn get_version_tolerates_null_buffers() {
2794        // None of these should crash.
2795        unsafe {
2796            GetIpoptVersion(
2797                std::ptr::null_mut(),
2798                std::ptr::null_mut(),
2799                std::ptr::null_mut(),
2800            )
2801        };
2802    }
2803
2804    #[test]
2805    fn set_scaling_stores_user_supplied_arrays() {
2806        let p = create_unconstrained();
2807        let xs = [2.0, 3.0, 4.0, 5.0];
2808        let ok = unsafe { SetIpoptProblemScaling(p, 7.0, xs.as_ptr(), std::ptr::null()) };
2809        assert_eq!(ok, TRUE);
2810        let info = unsafe { &*p };
2811        let s = info.user_scaling.as_ref().unwrap();
2812        assert_eq!(s.obj_scaling, 7.0);
2813        assert_eq!(s.x_scaling.as_deref(), Some(&xs[..]));
2814        assert!(s.g_scaling.is_none());
2815        unsafe { FreeIpoptProblem(p) };
2816    }
2817
2818    #[test]
2819    fn set_scaling_on_null_problem_returns_false() {
2820        let ok = unsafe {
2821            SetIpoptProblemScaling(
2822                std::ptr::null_mut(),
2823                1.0,
2824                std::ptr::null(),
2825                std::ptr::null(),
2826            )
2827        };
2828        assert_eq!(ok, FALSE);
2829    }
2830
2831    #[test]
2832    fn open_output_file_writes_and_attaches_journal() {
2833        let p = create_unconstrained();
2834        let dir = std::env::temp_dir().join("pounce-cinterface-test");
2835        let _ = std::fs::create_dir_all(&dir);
2836        let path = dir.join("output.log");
2837        let cstr = CString::new(path.to_string_lossy().as_bytes()).unwrap();
2838        let ok = unsafe { OpenIpoptOutputFile(p, cstr.as_ptr(), 5) };
2839        assert_eq!(ok, TRUE);
2840        // Option should be reflected in the app.
2841        let info = unsafe { &*p };
2842        let (level, found) = info
2843            .app
2844            .options()
2845            .get_integer_value("file_print_level", "")
2846            .unwrap();
2847        assert!(found);
2848        assert_eq!(level, 5);
2849        unsafe { FreeIpoptProblem(p) };
2850        let _ = std::fs::remove_file(&path);
2851    }
2852
2853    #[test]
2854    fn open_output_file_with_null_inputs_returns_false() {
2855        let key = CString::new("nope").unwrap();
2856        unsafe {
2857            assert_eq!(
2858                OpenIpoptOutputFile(std::ptr::null_mut(), key.as_ptr(), 0),
2859                FALSE
2860            );
2861        }
2862        let p = create_unconstrained();
2863        unsafe {
2864            assert_eq!(OpenIpoptOutputFile(p, std::ptr::null(), 0), FALSE);
2865            FreeIpoptProblem(p);
2866        }
2867    }
2868
2869    #[test]
2870    fn get_current_iterate_returns_false_outside_callback() {
2871        let p = create_unconstrained();
2872        let rc = unsafe {
2873            GetIpoptCurrentIterate(
2874                p,
2875                FALSE,
2876                0,
2877                std::ptr::null_mut(),
2878                std::ptr::null_mut(),
2879                std::ptr::null_mut(),
2880                0,
2881                std::ptr::null_mut(),
2882                std::ptr::null_mut(),
2883            )
2884        };
2885        assert_eq!(rc, FALSE);
2886        unsafe { FreeIpoptProblem(p) };
2887    }
2888
2889    #[test]
2890    fn get_current_violations_returns_false_outside_callback() {
2891        let p = create_unconstrained();
2892        let rc = unsafe {
2893            GetIpoptCurrentViolations(
2894                p,
2895                FALSE,
2896                0,
2897                std::ptr::null_mut(),
2898                std::ptr::null_mut(),
2899                std::ptr::null_mut(),
2900                std::ptr::null_mut(),
2901                std::ptr::null_mut(),
2902                0,
2903                std::ptr::null_mut(),
2904                std::ptr::null_mut(),
2905            )
2906        };
2907        assert_eq!(rc, FALSE);
2908        unsafe { FreeIpoptProblem(p) };
2909    }
2910
2911    #[test]
2912    fn post_solve_stats_zero_before_solve() {
2913        let p = create_unconstrained();
2914        unsafe {
2915            assert_eq!(GetIpoptIterCount(p), 0);
2916            assert_eq!(GetIpoptSolveTime(p), 0.0);
2917            assert_eq!(GetIpoptPrimalInf(p), 0.0);
2918            assert_eq!(GetIpoptDualInf(p), 0.0);
2919            assert_eq!(GetIpoptComplInf(p), 0.0);
2920            FreeIpoptProblem(p);
2921        }
2922    }
2923
2924    #[test]
2925    fn post_solve_stats_populated_after_solve() {
2926        // Reuse the same quadratic as the end-to-end solve test.
2927        let xl = [-1.0e20];
2928        let xu = [1.0e20];
2929        let p = unsafe {
2930            CreateIpoptProblem(
2931                1,
2932                xl.as_ptr(),
2933                xu.as_ptr(),
2934                0,
2935                std::ptr::null(),
2936                std::ptr::null(),
2937                0,
2938                1,
2939                0,
2940                Some(quad_eval_f),
2941                None,
2942                Some(quad_eval_grad_f),
2943                None,
2944                Some(quad_eval_h),
2945            )
2946        };
2947        let mut x = [0.0_f64];
2948        let mut obj = 0.0_f64;
2949        let rc = unsafe {
2950            IpoptSolve(
2951                p,
2952                x.as_mut_ptr(),
2953                std::ptr::null_mut(),
2954                &mut obj,
2955                std::ptr::null_mut(),
2956                std::ptr::null_mut(),
2957                std::ptr::null_mut(),
2958                std::ptr::null_mut(),
2959            )
2960        };
2961        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
2962        // After a successful solve, iter count is recorded (>= 0) and
2963        // wall time is non-negative; primal/dual/compl norms exist.
2964        unsafe {
2965            assert!(GetIpoptIterCount(p) >= 0);
2966            assert!(GetIpoptSolveTime(p) >= 0.0);
2967            assert!(GetIpoptPrimalInf(p).is_finite());
2968            assert!(GetIpoptDualInf(p).is_finite());
2969            assert!(GetIpoptComplInf(p).is_finite());
2970            FreeIpoptProblem(p);
2971        }
2972    }
2973
2974    /// The linear-solver post-mortem is reported for a real solve, and
2975    /// reports the backend that actually ran rather than the one the
2976    /// option asked for — which is the question a caller pinning
2977    /// `linear_solver` is really asking.
2978    #[test]
2979    fn linear_solver_stats_populated_after_solve() {
2980        let xl = [-1.0e20];
2981        let xu = [1.0e20];
2982        let p = unsafe {
2983            CreateIpoptProblem(
2984                1,
2985                xl.as_ptr(),
2986                xu.as_ptr(),
2987                0,
2988                std::ptr::null(),
2989                std::ptr::null(),
2990                0,
2991                1,
2992                0,
2993                Some(quad_eval_f),
2994                None,
2995                Some(quad_eval_grad_f),
2996                None,
2997                Some(quad_eval_h),
2998            )
2999        };
3000        let mut stats = unsafe { std::mem::zeroed::<PounceLinearSolverStats>() };
3001        // Nothing to report before the first solve.
3002        assert_eq!(unsafe { GetPounceLinearSolverStats(p, &mut stats) }, FALSE);
3003
3004        let mut x = [0.0_f64];
3005        let mut obj = 0.0_f64;
3006        let rc = unsafe {
3007            IpoptSolve(
3008                p,
3009                x.as_mut_ptr(),
3010                std::ptr::null_mut(),
3011                &mut obj,
3012                std::ptr::null_mut(),
3013                std::ptr::null_mut(),
3014                std::ptr::null_mut(),
3015                std::ptr::null_mut(),
3016            )
3017        };
3018        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
3019        assert_eq!(unsafe { GetPounceLinearSolverStats(p, &mut stats) }, TRUE);
3020
3021        let name = unsafe { CStr::from_ptr(stats.solver_name.as_ptr()) }
3022            .to_str()
3023            .expect("solver name is ASCII");
3024        assert_eq!(name, "feral", "default backend should report itself");
3025        assert!(stats.n_factors > 0, "n_factors = {}", stats.n_factors);
3026        assert_eq!(
3027            stats.n_pattern_reuse + stats.n_pattern_changes,
3028            stats.n_factors,
3029            "every factor is either a pattern reuse or a pattern change"
3030        );
3031        // Optional fields are either a real value or the documented
3032        // sentinel — never a silent zero.
3033        assert!(stats.max_fill_ratio.is_nan() || stats.max_fill_ratio > 0.0);
3034        assert!(stats.last_nnz_l == -1 || stats.last_nnz_l > 0);
3035        unsafe { FreeIpoptProblem(p) };
3036    }
3037
3038    #[test]
3039    fn option_type_reports_the_setter_a_keyword_expects() {
3040        let p = create_unconstrained();
3041        let ty = |s: &str| {
3042            let c = std::ffi::CString::new(s).unwrap();
3043            unsafe { GetPounceOptionType(p, c.as_ptr()) }
3044        };
3045        assert_eq!(ty("tol"), 1, "tol is a number");
3046        assert_eq!(ty("max_iter"), 2, "max_iter is an integer");
3047        assert_eq!(ty("linear_solver"), 3, "linear_solver is a string");
3048        // `hessian_approximation` is the option the CasADi plugin has to
3049        // set as a string while the user may well type it as one too.
3050        assert_eq!(ty("hessian_approximation"), 3);
3051        // Unregistered, and NULL keyword, both answer "unknown" rather
3052        // than guessing a type.
3053        assert_eq!(ty("no_such_option_at_all"), 0);
3054        assert_eq!(unsafe { GetPounceOptionType(p, std::ptr::null()) }, 0);
3055        unsafe { FreeIpoptProblem(p) };
3056    }
3057
3058    /// A NULL problem handle answers from the same registry — the case a
3059    /// code generator is in, having no problem to ask.
3060    #[test]
3061    fn option_type_answers_without_a_problem_handle() {
3062        let ty = |s: &str| {
3063            let c = std::ffi::CString::new(s).unwrap();
3064            unsafe { GetPounceOptionType(std::ptr::null_mut(), c.as_ptr()) }
3065        };
3066        assert_eq!(ty("tol"), 1);
3067        assert_eq!(ty("max_iter"), 2);
3068        assert_eq!(ty("linear_solver"), 3);
3069        assert_eq!(ty("no_such_option_at_all"), 0);
3070
3071        // …and the same answers a live problem gives, which is the
3072        // property that keeps the two paths from drifting apart.
3073        let p = create_unconstrained();
3074        for name in [
3075            "tol",
3076            "max_iter",
3077            "linear_solver",
3078            "mu_strategy",
3079            "print_level",
3080        ] {
3081            let c = std::ffi::CString::new(name).unwrap();
3082            assert_eq!(
3083                unsafe { GetPounceOptionType(std::ptr::null_mut(), c.as_ptr()) },
3084                unsafe { GetPounceOptionType(p, c.as_ptr()) },
3085                "handle-free and problem-bound disagree on {name}"
3086            );
3087        }
3088        unsafe { FreeIpoptProblem(p) };
3089    }
3090
3091    #[test]
3092    fn write_solve_report_emits_v1_json_with_iter_history() {
3093        // Quadratic — Newton driver, single iter; just exercises the
3094        // post-solve report path end-to-end.
3095        let xl = [-1.0e20];
3096        let xu = [1.0e20];
3097        let p = unsafe {
3098            CreateIpoptProblem(
3099                1,
3100                xl.as_ptr(),
3101                xu.as_ptr(),
3102                0,
3103                std::ptr::null(),
3104                std::ptr::null(),
3105                0,
3106                1,
3107                0,
3108                Some(quad_eval_f),
3109                None,
3110                Some(quad_eval_grad_f),
3111                None,
3112                Some(quad_eval_h),
3113            )
3114        };
3115
3116        // Write before any solve must fail.
3117        let cpath = CString::new("/tmp/pounce_cinterface_no_solve.json").unwrap();
3118        let bad = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), std::ptr::null()) };
3119        assert_eq!(bad, FALSE);
3120
3121        // Enable per-iter capture, solve, then write at detail = full.
3122        assert_eq!(unsafe { IpoptEnableIterHistory(p) }, TRUE);
3123        let mut x = [0.0_f64];
3124        let mut obj = 0.0_f64;
3125        let rc = unsafe {
3126            IpoptSolve(
3127                p,
3128                x.as_mut_ptr(),
3129                std::ptr::null_mut(),
3130                &mut obj,
3131                std::ptr::null_mut(),
3132                std::ptr::null_mut(),
3133                std::ptr::null_mut(),
3134                std::ptr::null_mut(),
3135            )
3136        };
3137        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
3138
3139        let dir = std::env::temp_dir();
3140        let path = dir.join("pounce_cinterface_report.json");
3141        let cpath = CString::new(path.to_str().unwrap()).unwrap();
3142        let cdetail = CString::new("full").unwrap();
3143        let ok = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), cdetail.as_ptr()) };
3144        assert_eq!(ok, TRUE);
3145
3146        // Read it back and check the schema tag + that it parses with
3147        // the same struct shape pounce-cli uses.
3148        let txt = std::fs::read_to_string(&path).unwrap();
3149        assert!(
3150            txt.contains("\"schema\": \"pounce.solve-report/v1\""),
3151            "{txt}"
3152        );
3153        assert!(txt.contains("\"kind\": \"tnlp-direct\""));
3154        let parsed: pounce_solve_report::SolveReport = serde_json::from_str(&txt).unwrap();
3155        assert_eq!(parsed.problem.n_variables, 1);
3156        assert_eq!(parsed.problem.n_constraints, 0);
3157
3158        // Invalid detail string is rejected.
3159        let bad_detail = CString::new("verbose").unwrap();
3160        let bad = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), bad_detail.as_ptr()) };
3161        assert_eq!(bad, FALSE);
3162
3163        let _ = std::fs::remove_file(&path);
3164        unsafe { FreeIpoptProblem(p) };
3165    }
3166
3167    // --- Intermediate-callback wiring (issue #19, follow-up) ---
3168    //
3169    // The callback only fires on the IPM path (`optimize_constrained`).
3170    // Unconstrained problems short-circuit through the Newton driver,
3171    // so these tests use a single-inequality problem to force the IPM.
3172
3173    unsafe extern "C" fn cb_quad_eval_g(
3174        _n: Index,
3175        x: *const Number,
3176        _new_x: Bool,
3177        _m: Index,
3178        g: *mut Number,
3179        _user_data: *mut c_void,
3180    ) -> Bool {
3181        unsafe {
3182            *g.offset(0) = *x.offset(0);
3183            TRUE
3184        }
3185    }
3186    unsafe extern "C" fn cb_quad_eval_jac_g(
3187        _n: Index,
3188        _x: *const Number,
3189        _new_x: Bool,
3190        _m: Index,
3191        nele_jac: Index,
3192        irow: *mut Index,
3193        jcol: *mut Index,
3194        values: *mut Number,
3195        _user_data: *mut c_void,
3196    ) -> Bool {
3197        unsafe {
3198            assert_eq!(nele_jac, 1);
3199            if !irow.is_null() {
3200                *irow.offset(0) = 0;
3201                *jcol.offset(0) = 0;
3202            }
3203            if !values.is_null() {
3204                *values.offset(0) = 1.0;
3205            }
3206            TRUE
3207        }
3208    }
3209    unsafe extern "C" fn cb_quad_eval_h(
3210        _n: Index,
3211        _x: *const Number,
3212        _new_x: Bool,
3213        obj_factor: Number,
3214        _m: Index,
3215        _lambda: *const Number,
3216        _new_lambda: Bool,
3217        _nele_hess: Index,
3218        irow: *mut Index,
3219        jcol: *mut Index,
3220        values: *mut Number,
3221        _user_data: *mut c_void,
3222    ) -> Bool {
3223        unsafe {
3224            if !irow.is_null() {
3225                *irow.offset(0) = 0;
3226                *jcol.offset(0) = 0;
3227            }
3228            if !values.is_null() {
3229                *values.offset(0) = 2.0 * obj_factor;
3230            }
3231            TRUE
3232        }
3233    }
3234
3235    fn create_callback_test_problem() -> IpoptProblem {
3236        // min (x - 2)^2  s.t.  -10 <= x <= 10 (single inequality).
3237        let xl = [-1.0e20];
3238        let xu = [1.0e20];
3239        let gl = [-10.0];
3240        let gu = [10.0];
3241        unsafe {
3242            CreateIpoptProblem(
3243                1,
3244                xl.as_ptr(),
3245                xu.as_ptr(),
3246                1,
3247                gl.as_ptr(),
3248                gu.as_ptr(),
3249                1,
3250                1,
3251                0,
3252                Some(quad_eval_f),
3253                Some(cb_quad_eval_g),
3254                Some(quad_eval_grad_f),
3255                Some(cb_quad_eval_jac_g),
3256                Some(cb_quad_eval_h),
3257            )
3258        }
3259    }
3260
3261    static CB_ITER_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
3262    static CB_LAST_ITER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
3263    static CB_INSPECTOR_OK: std::sync::atomic::AtomicBool =
3264        std::sync::atomic::AtomicBool::new(false);
3265
3266    unsafe extern "C" fn counting_cb(
3267        _alg_mod: Index,
3268        iter_count: Index,
3269        _obj_value: Number,
3270        _inf_pr: Number,
3271        _inf_du: Number,
3272        _mu: Number,
3273        _d_norm: Number,
3274        _regularization_size: Number,
3275        _alpha_du: Number,
3276        _alpha_pr: Number,
3277        _ls_trials: Index,
3278        user_data: *mut c_void,
3279    ) -> Bool {
3280        unsafe {
3281            CB_ITER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3282            CB_LAST_ITER.store(iter_count, std::sync::atomic::Ordering::SeqCst);
3283            // user_data carries the IpoptProblem so we can exercise the
3284            // inspector from inside the callback.
3285            let problem = user_data as IpoptProblem;
3286            let mut x = [0.0_f64];
3287            let rc = GetIpoptCurrentIterate(
3288                problem,
3289                FALSE,
3290                1,
3291                x.as_mut_ptr(),
3292                std::ptr::null_mut(),
3293                std::ptr::null_mut(),
3294                1,
3295                std::ptr::null_mut(),
3296                std::ptr::null_mut(),
3297            );
3298            if rc == TRUE && x[0].is_finite() {
3299                CB_INSPECTOR_OK.store(true, std::sync::atomic::Ordering::SeqCst);
3300            }
3301            TRUE
3302        }
3303    }
3304
3305    #[test]
3306    fn intermediate_callback_fires_per_iteration_and_inspector_reads_x() {
3307        CB_ITER_COUNTER.store(0, std::sync::atomic::Ordering::SeqCst);
3308        CB_LAST_ITER.store(-1, std::sync::atomic::Ordering::SeqCst);
3309        CB_INSPECTOR_OK.store(false, std::sync::atomic::Ordering::SeqCst);
3310
3311        let p = create_callback_test_problem();
3312        assert!(!p.is_null());
3313        let ok = unsafe { SetIntermediateCallback(p, Some(counting_cb)) };
3314        assert_eq!(ok, TRUE);
3315        let mut x = [0.0_f64];
3316        let mut obj = 0.0_f64;
3317        let rc = unsafe {
3318            IpoptSolve(
3319                p,
3320                x.as_mut_ptr(),
3321                std::ptr::null_mut(),
3322                &mut obj,
3323                std::ptr::null_mut(),
3324                std::ptr::null_mut(),
3325                std::ptr::null_mut(),
3326                p as *mut c_void,
3327            )
3328        };
3329        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
3330        // At least the iter-0 fire happened, plus one per accepted step.
3331        let n_fires = CB_ITER_COUNTER.load(std::sync::atomic::Ordering::SeqCst);
3332        assert!(n_fires >= 2, "callback fired {n_fires} times, want >=2");
3333        assert!(
3334            CB_LAST_ITER.load(std::sync::atomic::Ordering::SeqCst) >= 1,
3335            "last iter should be >= 1 after at least one accepted step"
3336        );
3337        assert!(
3338            CB_INSPECTOR_OK.load(std::sync::atomic::Ordering::SeqCst),
3339            "GetIpoptCurrentIterate did not return a usable x"
3340        );
3341        unsafe { FreeIpoptProblem(p) };
3342    }
3343
3344    static CB_VIOL_OK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
3345
3346    // Bounded variant of `create_callback_test_problem`: x in [0, 10] with a
3347    // finite lower bound, so the `x_l_violation` / `x_u_violation` branches of
3348    // GetIpoptCurrentViolations actually scatter a real `x_L`/`x_U` mapping
3349    // (not the degenerate "no bound" pack).
3350    fn create_bounded_callback_test_problem() -> IpoptProblem {
3351        // min (x - 2)^2  s.t.  -10 <= x <= 10,  x in [0, 10].
3352        let xl = [0.0];
3353        let xu = [10.0];
3354        let gl = [-10.0];
3355        let gu = [10.0];
3356        unsafe {
3357            CreateIpoptProblem(
3358                1,
3359                xl.as_ptr(),
3360                xu.as_ptr(),
3361                1,
3362                gl.as_ptr(),
3363                gu.as_ptr(),
3364                1,
3365                1,
3366                0,
3367                Some(quad_eval_f),
3368                Some(cb_quad_eval_g),
3369                Some(quad_eval_grad_f),
3370                Some(cb_quad_eval_jac_g),
3371                Some(cb_quad_eval_h),
3372            )
3373        }
3374    }
3375
3376    unsafe extern "C" fn violations_inspecting_cb(
3377        _alg_mod: Index,
3378        _iter_count: Index,
3379        _obj_value: Number,
3380        _inf_pr: Number,
3381        _inf_du: Number,
3382        _mu: Number,
3383        _d_norm: Number,
3384        _regularization_size: Number,
3385        _alpha_du: Number,
3386        _alpha_pr: Number,
3387        _ls_trials: Index,
3388        user_data: *mut c_void,
3389    ) -> Bool {
3390        unsafe {
3391            let problem = user_data as IpoptProblem;
3392            // Exercise the bound-violation branches (n=1, m=1) from inside an
3393            // installed intermediate context. Pre-L51 these branches indexed
3394            // `v[i]` without a length guard; the fix makes them return FALSE on
3395            // a packed-length mismatch instead of panicking across `extern "C"`.
3396            let mut x_l_viol = [f64::NAN];
3397            let mut x_u_viol = [f64::NAN];
3398            let rc = GetIpoptCurrentViolations(
3399                problem,
3400                FALSE,
3401                1,
3402                x_l_viol.as_mut_ptr(),
3403                x_u_viol.as_mut_ptr(),
3404                std::ptr::null_mut(),
3405                std::ptr::null_mut(),
3406                std::ptr::null_mut(),
3407                1,
3408                std::ptr::null_mut(),
3409                std::ptr::null_mut(),
3410            );
3411            if rc == TRUE
3412                && x_l_viol[0].is_finite()
3413                && x_l_viol[0] >= 0.0
3414                && x_u_viol[0].is_finite()
3415                && x_u_viol[0] >= 0.0
3416            {
3417                CB_VIOL_OK.store(true, std::sync::atomic::Ordering::SeqCst);
3418            }
3419            TRUE
3420        }
3421    }
3422
3423    #[test]
3424    fn get_current_violations_inside_callback_reports_finite_bounds() {
3425        CB_VIOL_OK.store(false, std::sync::atomic::Ordering::SeqCst);
3426        let p = create_bounded_callback_test_problem();
3427        assert!(!p.is_null());
3428        let ok = unsafe { SetIntermediateCallback(p, Some(violations_inspecting_cb)) };
3429        assert_eq!(ok, TRUE);
3430        let mut x = [5.0_f64];
3431        let mut obj = 0.0_f64;
3432        let rc = unsafe {
3433            IpoptSolve(
3434                p,
3435                x.as_mut_ptr(),
3436                std::ptr::null_mut(),
3437                &mut obj,
3438                std::ptr::null_mut(),
3439                std::ptr::null_mut(),
3440                std::ptr::null_mut(),
3441                p as *mut c_void,
3442            )
3443        };
3444        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
3445        assert!(
3446            CB_VIOL_OK.load(std::sync::atomic::Ordering::SeqCst),
3447            "GetIpoptCurrentViolations did not return finite, non-negative \
3448             bound violations from inside the callback"
3449        );
3450        unsafe { FreeIpoptProblem(p) };
3451    }
3452
3453    #[test]
3454    fn bound_violation_scatter_rejects_oversized_pack_instead_of_panicking() {
3455        // L51 fail-first (logic level): reproduce the scatter of the
3456        // `x_l_violation` / `x_u_violation` branches. The packed vector comes
3457        // from `pack_z_*_for_user`, whose length must equal the output `n`.
3458        // Pre-fix the branches scattered it with `for (i, s) in
3459        // packed.enumerate() { v[i] = ... }` over a `vec![0.0; n]` *without*
3460        // checking the length; an oversized pack indexes `v[i]` out of bounds
3461        // and panics — and across the real `extern "C"` boundary that panic
3462        // aborts the embedding process. The fix adds the same length guard
3463        // the sibling (`compl_*`, `grad_lag_x`) branches already had.
3464        let n_us = 1usize;
3465        let packed = vec![0.5_f64, -0.3]; // len 2 != n_us == 1
3466
3467        // Pre-fix: the unguarded scatter panics on the oversized pack.
3468        let unguarded = std::panic::catch_unwind(|| {
3469            let mut v = vec![0.0; n_us];
3470            for (i, s) in packed.iter().enumerate() {
3471                v[i] = (-s).max(0.0);
3472            }
3473            v
3474        });
3475        assert!(
3476            unguarded.is_err(),
3477            "unguarded scatter should panic (→ abort across extern \"C\") on an oversized pack"
3478        );
3479
3480        // Post-fix: the length guard returns an error instead of panicking.
3481        let guarded: Result<Vec<f64>, ()> = (|| {
3482            if packed.len() != n_us {
3483                return Err(());
3484            }
3485            let mut v = vec![0.0; n_us];
3486            for (i, s) in packed.iter().enumerate() {
3487                v[i] = (-s).max(0.0);
3488            }
3489            Ok(v)
3490        })();
3491        assert!(
3492            guarded.is_err(),
3493            "guarded scatter should reject the length mismatch (return FALSE), not panic"
3494        );
3495    }
3496
3497    unsafe extern "C" fn user_stop_cb(
3498        _alg_mod: Index,
3499        _iter_count: Index,
3500        _obj_value: Number,
3501        _inf_pr: Number,
3502        _inf_du: Number,
3503        _mu: Number,
3504        _d_norm: Number,
3505        _regularization_size: Number,
3506        _alpha_du: Number,
3507        _alpha_pr: Number,
3508        _ls_trials: Index,
3509        _user_data: *mut c_void,
3510    ) -> Bool {
3511        FALSE
3512    }
3513
3514    #[test]
3515    fn intermediate_callback_false_surfaces_user_requested_stop() {
3516        let p = create_callback_test_problem();
3517        assert!(!p.is_null());
3518        let ok = unsafe { SetIntermediateCallback(p, Some(user_stop_cb)) };
3519        assert_eq!(ok, TRUE);
3520        let mut x = [0.0_f64];
3521        let rc = unsafe {
3522            IpoptSolve(
3523                p,
3524                x.as_mut_ptr(),
3525                std::ptr::null_mut(),
3526                std::ptr::null_mut(),
3527                std::ptr::null_mut(),
3528                std::ptr::null_mut(),
3529                std::ptr::null_mut(),
3530                std::ptr::null_mut(),
3531            )
3532        };
3533        assert_eq!(rc, ApplicationReturnStatus::UserRequestedStop as Index);
3534        unsafe { FreeIpoptProblem(p) };
3535    }
3536
3537    #[test]
3538    fn ffi_guard_converts_panic_to_fallback() {
3539        // L56: a panic in pounce's own Rust code during a solve must be
3540        // caught at the FFI boundary and reported as `Internal_Error`, never
3541        // unwound across `extern "C"` (which aborts the embedding process).
3542        // This exercises the exact mechanism wrapping IpoptSolve /
3543        // IpoptSolveWarmStart. (The "boom" panic message printing to stderr
3544        // is expected — the default panic hook still runs before the catch.)
3545        let fallback = ApplicationReturnStatus::InternalError as Index;
3546        let got = ffi_guard(fallback, || -> Index {
3547            panic!("boom inside solver core");
3548        });
3549        assert_eq!(got, fallback);
3550        assert_eq!(got, ApplicationReturnStatus::InternalError as Index);
3551    }
3552
3553    #[test]
3554    fn ffi_guard_is_transparent_on_success() {
3555        // On the happy path the guard returns the body's value unchanged, so
3556        // wrapping IpoptSolve does not alter normal solves (the end-to-end
3557        // solve tests above confirm this at the public-API level).
3558        let got = ffi_guard(-99, || 7);
3559        assert_eq!(got, 7);
3560    }
3561
3562    #[test]
3563    fn parse_pkg_version_handles_missing_components() {
3564        assert_eq!(parse_pkg_version("1.2.3"), (1, 2, 3));
3565        assert_eq!(parse_pkg_version("4.5"), (4, 5, 0));
3566        assert_eq!(parse_pkg_version(""), (0, 0, 0));
3567        assert_eq!(parse_pkg_version("1.x.3"), (1, 0, 3));
3568    }
3569
3570    // ---- Solver-session C ABI (crate::solver) ----
3571
3572    use crate::solver::{
3573        IpoptCreateSolver, IpoptFreeSolver, IpoptSolverGetKktDim, IpoptSolverKktSolve,
3574        IpoptSolverSolve,
3575    };
3576
3577    #[test]
3578    fn solver_create_consumes_problem_handle() {
3579        let mut p = create_unconstrained();
3580        assert!(!p.is_null());
3581        let s = unsafe { IpoptCreateSolver(&mut p) };
3582        assert!(!s.is_null());
3583        assert!(
3584            p.is_null(),
3585            "IpoptCreateSolver should NULL out the caller's handle"
3586        );
3587        unsafe { IpoptFreeSolver(s) };
3588    }
3589
3590    #[test]
3591    fn solver_create_null_inputs_return_null() {
3592        // NULL pointer-to-handle.
3593        let s = unsafe { IpoptCreateSolver(std::ptr::null_mut()) };
3594        assert!(s.is_null());
3595        // Pointer to a NULL handle.
3596        let mut p: IpoptProblem = std::ptr::null_mut();
3597        let s = unsafe { IpoptCreateSolver(&mut p) };
3598        assert!(s.is_null());
3599    }
3600
3601    #[test]
3602    fn solver_free_null_is_safe() {
3603        unsafe { IpoptFreeSolver(std::ptr::null_mut()) };
3604    }
3605
3606    #[test]
3607    fn solver_solve_drives_quadratic_and_retains_factor() {
3608        let xl = [-1.0e20];
3609        let xu = [1.0e20];
3610        let mut p = unsafe {
3611            CreateIpoptProblem(
3612                1,
3613                xl.as_ptr(),
3614                xu.as_ptr(),
3615                0,
3616                std::ptr::null(),
3617                std::ptr::null(),
3618                0,
3619                1,
3620                0,
3621                Some(quad_eval_f),
3622                None,
3623                Some(quad_eval_grad_f),
3624                None,
3625                Some(quad_eval_h),
3626            )
3627        };
3628        assert!(!p.is_null());
3629        let s = unsafe { IpoptCreateSolver(&mut p) };
3630        assert!(!s.is_null());
3631        let mut x = [0.0_f64];
3632        let mut obj = 0.0_f64;
3633        let rc = unsafe {
3634            IpoptSolverSolve(
3635                s,
3636                x.as_mut_ptr(),
3637                std::ptr::null_mut(),
3638                &mut obj,
3639                std::ptr::null_mut(),
3640                std::ptr::null_mut(),
3641                std::ptr::null_mut(),
3642                std::ptr::null_mut(),
3643            )
3644        };
3645        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
3646        assert!((x[0] - 2.0).abs() < 1e-6);
3647        assert!(obj.abs() < 1e-10);
3648
3649        // After convergence the factor is retained — kkt_dim is positive
3650        // and a zero RHS back-solves to zero.
3651        let dim = unsafe { IpoptSolverGetKktDim(s) };
3652        assert!(dim > 0, "expected positive KKT dim, got {dim}");
3653        let rhs = vec![0.0_f64; dim as usize];
3654        let mut lhs = vec![1.0_f64; dim as usize];
3655        let ok = unsafe { IpoptSolverKktSolve(s, rhs.as_ptr(), lhs.as_mut_ptr()) };
3656        assert_eq!(ok, TRUE);
3657        for (i, v) in lhs.iter().enumerate() {
3658            assert!(v.abs() < 1e-10, "lhs[{i}] = {v} not ~0");
3659        }
3660        unsafe { IpoptFreeSolver(s) };
3661    }
3662
3663    #[test]
3664    fn solver_kkt_dim_minus_one_before_solve() {
3665        let mut p = create_unconstrained();
3666        let s = unsafe { IpoptCreateSolver(&mut p) };
3667        assert_eq!(unsafe { IpoptSolverGetKktDim(s) }, -1);
3668        unsafe { IpoptFreeSolver(s) };
3669    }
3670
3671    // ─────────────────────────────────────────────────────────
3672    // §7.2 SQP working-set warm-start C ABI tests.
3673    // ─────────────────────────────────────────────────────────
3674
3675    #[test]
3676    fn c_get_working_set_returns_false_before_any_solve() {
3677        let p = create_unconstrained();
3678        let mut bound_buf = [0; 4];
3679        let rc = unsafe { IpoptGetWorkingSet(p, bound_buf.as_mut_ptr(), std::ptr::null_mut()) };
3680        assert_eq!(rc, FALSE);
3681        unsafe { FreeIpoptProblem(p) };
3682    }
3683
3684    #[test]
3685    fn c_set_warm_start_with_both_null_returns_false() {
3686        let p = create_unconstrained();
3687        let rc = unsafe { IpoptSetWarmStartWorkingSet(p, std::ptr::null(), std::ptr::null()) };
3688        assert_eq!(rc, FALSE);
3689        unsafe { FreeIpoptProblem(p) };
3690    }
3691
3692    #[test]
3693    fn c_set_warm_start_with_bad_status_code_returns_false() {
3694        let p = create_unconstrained();
3695        // Length n = 4; '7' is out of range (valid: 0..=3).
3696        let bogus = [
3697            POUNCE_WS_INACTIVE,
3698            7,
3699            POUNCE_WS_AT_LOWER,
3700            POUNCE_WS_INACTIVE,
3701        ];
3702        let rc = unsafe { IpoptSetWarmStartWorkingSet(p, bogus.as_ptr(), std::ptr::null()) };
3703        assert_eq!(rc, FALSE);
3704        unsafe { FreeIpoptProblem(p) };
3705    }
3706
3707    #[test]
3708    fn c_set_warm_start_then_clear_succeeds() {
3709        let p = create_unconstrained();
3710        let in_buf = [POUNCE_WS_INACTIVE; 4];
3711        let set_rc = unsafe { IpoptSetWarmStartWorkingSet(p, in_buf.as_ptr(), std::ptr::null()) };
3712        assert_eq!(set_rc, TRUE);
3713        let clr_rc = unsafe { IpoptClearWarmStartWorkingSet(p) };
3714        assert_eq!(clr_rc, TRUE);
3715        unsafe { FreeIpoptProblem(p) };
3716    }
3717
3718    #[test]
3719    fn c_set_warm_start_on_null_problem_returns_false() {
3720        let in_buf = [POUNCE_WS_INACTIVE; 1];
3721        let rc = unsafe {
3722            IpoptSetWarmStartWorkingSet(std::ptr::null_mut(), in_buf.as_ptr(), std::ptr::null())
3723        };
3724        assert_eq!(rc, FALSE);
3725    }
3726
3727    #[test]
3728    fn c_solve_warm_start_round_trips_working_set_on_sqp_path() {
3729        // Use the 1-D `(x − 2)²` quadratic from
3730        // `create_callback_test_problem`. Set `algorithm
3731        // active-set-sqp`, solve, then read the working set
3732        // through `IpoptGetWorkingSet`. Pass it back via
3733        // `IpoptSolveWarmStart` for a second solve.
3734        let p = create_callback_test_problem();
3735        let key = CString::new("algorithm").unwrap();
3736        let val = CString::new("active-set-sqp").unwrap();
3737        let ok = unsafe { AddIpoptStrOption(p, key.as_ptr(), val.as_ptr()) };
3738        assert_eq!(ok, TRUE);
3739
3740        let mut x = [0.0_f64];
3741        let mut obj = 0.0_f64;
3742        let rc1 = unsafe {
3743            IpoptSolve(
3744                p,
3745                x.as_mut_ptr(),
3746                std::ptr::null_mut(),
3747                &mut obj,
3748                std::ptr::null_mut(),
3749                std::ptr::null_mut(),
3750                std::ptr::null_mut(),
3751                std::ptr::null_mut(),
3752            )
3753        };
3754        assert_eq!(rc1, ApplicationReturnStatus::SolveSucceeded as Index);
3755
3756        let mut bound_buf = [-1; 1];
3757        let mut cons_buf = [-1; 1];
3758        let got = unsafe { IpoptGetWorkingSet(p, bound_buf.as_mut_ptr(), cons_buf.as_mut_ptr()) };
3759        assert_eq!(got, TRUE);
3760        // Status codes must be in 0..=3.
3761        assert!((0..=3).contains(&bound_buf[0]));
3762        assert!((0..=3).contains(&cons_buf[0]));
3763
3764        // Second solve with the just-retrieved working set as
3765        // input. Resets x to a non-optimal starting point so the
3766        // SQP loop actually has work to do; the warm-start
3767        // should still converge to the optimum.
3768        x[0] = 0.0;
3769        let mut obj2 = 0.0_f64;
3770        let mut bound_out = [-1; 1];
3771        let mut cons_out = [-1; 1];
3772        let rc2 = unsafe {
3773            IpoptSolveWarmStart(
3774                p,
3775                x.as_mut_ptr(),
3776                std::ptr::null_mut(),
3777                &mut obj2,
3778                std::ptr::null_mut(),
3779                std::ptr::null_mut(),
3780                std::ptr::null_mut(),
3781                bound_buf.as_ptr(),
3782                cons_buf.as_ptr(),
3783                bound_out.as_mut_ptr(),
3784                cons_out.as_mut_ptr(),
3785                std::ptr::null_mut(),
3786            )
3787        };
3788        assert_eq!(rc2, ApplicationReturnStatus::SolveSucceeded as Index);
3789        assert!((0..=3).contains(&bound_out[0]));
3790        assert!((0..=3).contains(&cons_out[0]));
3791
3792        unsafe { FreeIpoptProblem(p) };
3793    }
3794}