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