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