Skip to main content

pounce_cinterface/
lib.rs

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