Skip to main content

pounce_cinterface/
lib.rs

1//! POUNCE C ABI — port of `Interfaces/IpStdCInterface.{h,cpp}`.
2//!
3//! Provides the `IpoptCreate / IpoptSolve / IpoptFreeProblem` C entry
4//! 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::alg_builder::AlgorithmBuilder;
38use pounce_algorithm::application::{
39    default_backend_factory, feral_config_from_options, IpoptApplication,
40};
41use pounce_algorithm::intermediate as ip_intermediate;
42use pounce_nlp::return_codes::ApplicationReturnStatus;
43use pounce_nlp::solve_statistics::SolveStatistics;
44use pounce_nlp::tnlp::{
45    BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution, SparsityRequest,
46    StartingPoint, TNLP,
47};
48use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
49use pounce_restoration::resto_inner_solver::{
50    make_default_restoration_factory, InnerBackendFactoryFactory,
51};
52use std::cell::RefCell;
53use std::ffi::{c_char, c_int, c_void, CStr};
54use std::rc::Rc;
55
56/// Mirrors C `Number` typedef in `IpStdCInterface.h`.
57pub type Number = f64;
58/// Mirrors C `Index`.
59pub type Index = c_int;
60/// Mirrors C `Bool`.
61pub type Bool = c_int;
62
63const TRUE: Bool = 1;
64const FALSE: Bool = 0;
65
66/// Internal owned state behind the opaque `IpoptProblem` handle.
67/// `#[repr(C)]` is unnecessary because C only sees the pointer.
68pub struct IpoptProblemInfo {
69    pub(crate) app: IpoptApplication,
70    pub(crate) n: Index,
71    pub(crate) m: Index,
72    pub(crate) nele_jac: Index,
73    pub(crate) nele_hess: Index,
74    pub(crate) index_style: Index,
75    pub(crate) x_l: Vec<Number>,
76    pub(crate) x_u: Vec<Number>,
77    pub(crate) g_l: Vec<Number>,
78    pub(crate) g_u: Vec<Number>,
79    pub(crate) eval_f: Option<Eval_F_CB>,
80    pub(crate) eval_g: Option<Eval_G_CB>,
81    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
82    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
83    pub(crate) eval_h: Option<Eval_H_CB>,
84    pub(crate) intermediate_cb: Option<Intermediate_CB>,
85    /// User-provided scaling installed by [`SetIpoptProblemScaling`].
86    /// `obj_scaling` defaults to `1.0`. `x_scaling`/`g_scaling` are
87    /// `None` when the user passed NULL.
88    pub(crate) user_scaling: Option<UserScaling>,
89    /// Final iterate and stats from the most recent [`IpoptSolve`].
90    /// Used by `GetIpopt{IterCount,SolveTime,...}` accessors. Reset
91    /// (cleared) by the next `IpoptSolve` call.
92    pub(crate) last_solve: Option<LastSolve>,
93}
94
95/// User-provided NLP scaling stored on the problem until
96/// [`IpoptSolve`] copies it into the [`CCallbackTnlp`] bridge.
97#[derive(Clone)]
98pub(crate) struct UserScaling {
99    obj_scaling: Number,
100    x_scaling: Option<Vec<Number>>,
101    g_scaling: Option<Vec<Number>>,
102}
103
104/// Stats and final-iterate snapshot retained between
105/// [`IpoptSolve`] and the post-solve accessors.
106#[derive(Clone, Default)]
107pub(crate) struct LastSolve {
108    pub(crate) stats: SolveStatistics,
109}
110
111pub type IpoptProblem = *mut IpoptProblemInfo;
112
113// User-callback function pointer types — match
114// `IpStdCInterface.h:Eval_F_CB` etc. byte for byte.
115
116pub type Eval_F_CB = unsafe extern "C" fn(
117    n: Index,
118    x: *const Number,
119    new_x: Bool,
120    obj_value: *mut Number,
121    user_data: *mut c_void,
122) -> Bool;
123
124pub type Eval_Grad_F_CB = unsafe extern "C" fn(
125    n: Index,
126    x: *const Number,
127    new_x: Bool,
128    grad_f: *mut Number,
129    user_data: *mut c_void,
130) -> Bool;
131
132pub type Eval_G_CB = unsafe extern "C" fn(
133    n: Index,
134    x: *const Number,
135    new_x: Bool,
136    m: Index,
137    g: *mut Number,
138    user_data: *mut c_void,
139) -> Bool;
140
141pub type Eval_Jac_G_CB = unsafe extern "C" fn(
142    n: Index,
143    x: *const Number,
144    new_x: Bool,
145    m: Index,
146    nele_jac: Index,
147    iRow: *mut Index,
148    jCol: *mut Index,
149    values: *mut Number,
150    user_data: *mut c_void,
151) -> Bool;
152
153pub type Eval_H_CB = unsafe extern "C" fn(
154    n: Index,
155    x: *const Number,
156    new_x: Bool,
157    obj_factor: Number,
158    m: Index,
159    lambda: *const Number,
160    new_lambda: Bool,
161    nele_hess: Index,
162    iRow: *mut Index,
163    jCol: *mut Index,
164    values: *mut Number,
165    user_data: *mut c_void,
166) -> Bool;
167
168pub type Intermediate_CB = unsafe extern "C" fn(
169    alg_mod: Index,
170    iter_count: Index,
171    obj_value: Number,
172    inf_pr: Number,
173    inf_du: Number,
174    mu: Number,
175    d_norm: Number,
176    regularization_size: Number,
177    alpha_du: Number,
178    alpha_pr: Number,
179    ls_trials: Index,
180    user_data: *mut c_void,
181) -> Bool;
182
183/// Port of `IpStdCInterface.cpp:CreateIpoptProblem`. Returns NULL on
184/// invalid arguments (negative n/m, missing required callbacks, NULL
185/// bound pointers when the corresponding dimension is positive).
186///
187/// # Safety
188///
189/// `x_L`, `x_U` must be valid pointers to `n` `Number`s when `n > 0`.
190/// `g_L`, `g_U` must be valid pointers to `m` `Number`s when `m > 0`.
191/// The callback function pointers must be valid for the lifetime of
192/// the returned [`IpoptProblem`].
193#[no_mangle]
194pub unsafe extern "C" fn CreateIpoptProblem(
195    n: Index,
196    x_L: *const Number,
197    x_U: *const Number,
198    m: Index,
199    g_L: *const Number,
200    g_U: *const Number,
201    nele_jac: Index,
202    nele_hess: Index,
203    index_style: Index,
204    eval_f: Option<Eval_F_CB>,
205    eval_g: Option<Eval_G_CB>,
206    eval_grad_f: Option<Eval_Grad_F_CB>,
207    eval_jac_g: Option<Eval_Jac_G_CB>,
208    eval_h: Option<Eval_H_CB>,
209) -> IpoptProblem {
210    if n < 0 || m < 0 || nele_jac < 0 || nele_hess < 0 {
211        return std::ptr::null_mut();
212    }
213    if !(0..=1).contains(&index_style) {
214        return std::ptr::null_mut();
215    }
216    if eval_f.is_none() || eval_grad_f.is_none() {
217        return std::ptr::null_mut();
218    }
219    if m > 0 && (eval_g.is_none() || eval_jac_g.is_none()) {
220        return std::ptr::null_mut();
221    }
222    if n > 0 && (x_L.is_null() || x_U.is_null()) {
223        return std::ptr::null_mut();
224    }
225    if m > 0 && (g_L.is_null() || g_U.is_null()) {
226        return std::ptr::null_mut();
227    }
228
229    let x_l = if n > 0 {
230        std::slice::from_raw_parts(x_L, n as usize).to_vec()
231    } else {
232        Vec::new()
233    };
234    let x_u = if n > 0 {
235        std::slice::from_raw_parts(x_U, n as usize).to_vec()
236    } else {
237        Vec::new()
238    };
239    let g_l_vec = if m > 0 {
240        std::slice::from_raw_parts(g_L, m as usize).to_vec()
241    } else {
242        Vec::new()
243    };
244    let g_u_vec = if m > 0 {
245        std::slice::from_raw_parts(g_U, m as usize).to_vec()
246    } else {
247        Vec::new()
248    };
249
250    let info = Box::new(IpoptProblemInfo {
251        app: IpoptApplication::new(),
252        n,
253        m,
254        nele_jac,
255        nele_hess,
256        index_style,
257        x_l,
258        x_u,
259        g_l: g_l_vec,
260        g_u: g_u_vec,
261        eval_f,
262        eval_g,
263        eval_grad_f,
264        eval_jac_g,
265        eval_h,
266        intermediate_cb: None,
267        user_scaling: None,
268        last_solve: None,
269    });
270    Box::into_raw(info)
271}
272
273/// Port of `IpStdCInterface.cpp:FreeIpoptProblem`.
274///
275/// # Safety
276///
277/// `ipopt_problem` must be a pointer previously returned by
278/// [`CreateIpoptProblem`] and not yet freed, or NULL.
279#[no_mangle]
280pub unsafe extern "C" fn FreeIpoptProblem(ipopt_problem: IpoptProblem) {
281    if ipopt_problem.is_null() {
282        return;
283    }
284    drop(Box::from_raw(ipopt_problem));
285}
286
287unsafe fn keyword_str<'a>(keyword: *const c_char) -> Option<&'a str> {
288    if keyword.is_null() {
289        return None;
290    }
291    CStr::from_ptr(keyword).to_str().ok()
292}
293
294/// Port of `IpStdCInterface.cpp:AddIpoptStrOption`.
295///
296/// # Safety
297///
298/// `ipopt_problem` must be a valid `IpoptProblem`. `keyword` and `val`
299/// must be valid NUL-terminated strings.
300#[no_mangle]
301pub unsafe extern "C" fn AddIpoptStrOption(
302    ipopt_problem: IpoptProblem,
303    keyword: *const c_char,
304    val: *const c_char,
305) -> Bool {
306    if ipopt_problem.is_null() {
307        return FALSE;
308    }
309    let info = &mut *ipopt_problem;
310    let Some(k) = keyword_str(keyword) else {
311        return FALSE;
312    };
313    if val.is_null() {
314        return FALSE;
315    }
316    let Ok(v) = CStr::from_ptr(val).to_str() else {
317        return FALSE;
318    };
319    match info.app.options_mut().set_string_value(k, v, true, false) {
320        Ok(_) => TRUE,
321        Err(_) => FALSE,
322    }
323}
324
325/// Port of `AddIpoptNumOption`.
326///
327/// # Safety
328///
329/// `keyword` must be a valid NUL-terminated string and
330/// `ipopt_problem` must be a valid `IpoptProblem`.
331#[no_mangle]
332pub unsafe extern "C" fn AddIpoptNumOption(
333    ipopt_problem: IpoptProblem,
334    keyword: *const c_char,
335    val: Number,
336) -> Bool {
337    if ipopt_problem.is_null() {
338        return FALSE;
339    }
340    let info = &mut *ipopt_problem;
341    let Some(k) = keyword_str(keyword) else {
342        return FALSE;
343    };
344    match info
345        .app
346        .options_mut()
347        .set_numeric_value(k, val, true, false)
348    {
349        Ok(_) => TRUE,
350        Err(_) => FALSE,
351    }
352}
353
354/// Port of `AddIpoptIntOption`.
355///
356/// # Safety
357///
358/// `keyword` must be a valid NUL-terminated string and
359/// `ipopt_problem` must be a valid `IpoptProblem`.
360#[no_mangle]
361pub unsafe extern "C" fn AddIpoptIntOption(
362    ipopt_problem: IpoptProblem,
363    keyword: *const c_char,
364    val: Index,
365) -> Bool {
366    if ipopt_problem.is_null() {
367        return FALSE;
368    }
369    let info = &mut *ipopt_problem;
370    let Some(k) = keyword_str(keyword) else {
371        return FALSE;
372    };
373    match info.app.options_mut().set_integer_value(
374        k,
375        val as pounce_common::types::Index,
376        true,
377        false,
378    ) {
379        Ok(_) => TRUE,
380        Err(_) => FALSE,
381    }
382}
383
384/// Port of `IpStdCInterface.cpp:OpenIpoptOutputFile`. Opens `file_name`
385/// at `print_level` and attaches a journalist `FileJournal` so all
386/// solver output is mirrored to disk. Equivalent to setting
387/// `output_file` + `file_print_level` options and triggering
388/// `IpoptApplication::Initialize`.
389///
390/// Returns `TRUE` (1) on success, `FALSE` (0) if the file could not
391/// be opened or the option store rejected the value.
392///
393/// # Safety
394///
395/// `ipopt_problem` must be a valid `IpoptProblem`. `file_name` must
396/// be a valid NUL-terminated string.
397#[no_mangle]
398pub unsafe extern "C" fn OpenIpoptOutputFile(
399    ipopt_problem: IpoptProblem,
400    file_name: *const c_char,
401    print_level: c_int,
402) -> Bool {
403    if ipopt_problem.is_null() || file_name.is_null() {
404        return FALSE;
405    }
406    let info = &mut *ipopt_problem;
407    let Ok(fname) = CStr::from_ptr(file_name).to_str() else {
408        return FALSE;
409    };
410    if info.app.open_output_file(fname, print_level) {
411        TRUE
412    } else {
413        FALSE
414    }
415}
416
417/// Port of `IpStdCInterface.cpp:SetIpoptProblemScaling`. Stores
418/// user-provided NLP scaling on the problem; the scaling is forwarded
419/// to the solver via [`TNLP::get_scaling_parameters`] when the option
420/// `nlp_scaling_method=user-scaling` is set. Passing NULL for
421/// `x_scaling` / `g_scaling` disables scaling on that axis.
422///
423/// Always returns `TRUE`.
424///
425/// # Safety
426///
427/// `ipopt_problem` must be a valid `IpoptProblem`. When non-NULL,
428/// `x_scaling` must point to `n` doubles and `g_scaling` to `m`
429/// doubles; both arrays are copied internally.
430#[no_mangle]
431pub unsafe extern "C" fn SetIpoptProblemScaling(
432    ipopt_problem: IpoptProblem,
433    obj_scaling: Number,
434    x_scaling: *const Number,
435    g_scaling: *const Number,
436) -> Bool {
437    if ipopt_problem.is_null() {
438        return FALSE;
439    }
440    let info = &mut *ipopt_problem;
441    let n = info.n as usize;
442    let m = info.m as usize;
443    let x_vec = if !x_scaling.is_null() && n > 0 {
444        Some(std::slice::from_raw_parts(x_scaling, n).to_vec())
445    } else {
446        None
447    };
448    let g_vec = if !g_scaling.is_null() && m > 0 {
449        Some(std::slice::from_raw_parts(g_scaling, m).to_vec())
450    } else {
451        None
452    };
453    info.user_scaling = Some(UserScaling {
454        obj_scaling,
455        x_scaling: x_vec,
456        g_scaling: g_vec,
457    });
458    TRUE
459}
460
461/// Port of `IpStdCInterface.cpp:IpoptSolve`. Returns the
462/// `ApplicationReturnStatus` integer.
463///
464/// Builds a [`CCallbackTnlp`] from the user-supplied callback table
465/// and bounds, runs it through [`IpoptApplication::optimize_tnlp`],
466/// and writes back the final iterate.
467///
468/// # Safety
469///
470/// All pointer arguments are read/written per the
471/// `IpStdCInterface.h` contract: `x` is in/out (size `n`); `g`,
472/// `mult_g`, `mult_x_L`, `mult_x_U` are out-only (sizes `m, m, n, n`)
473/// and may be NULL when the corresponding output is not desired.
474#[allow(clippy::too_many_arguments)]
475#[no_mangle]
476pub unsafe extern "C" fn IpoptSolve(
477    ipopt_problem: IpoptProblem,
478    x: *mut Number,
479    g: *mut Number,
480    obj_val: *mut Number,
481    mult_g: *mut Number,
482    mult_x_L: *mut Number,
483    mult_x_U: *mut Number,
484    user_data: *mut c_void,
485) -> Index {
486    if ipopt_problem.is_null() {
487        return ApplicationReturnStatus::InternalError as Index;
488    }
489    let info = &mut *ipopt_problem;
490    if info.n < 0 || info.m < 0 {
491        return ApplicationReturnStatus::InvalidProblemDefinition as Index;
492    }
493    if info.n > 0 && x.is_null() {
494        return ApplicationReturnStatus::InvalidProblemDefinition as Index;
495    }
496
497    let n_us = info.n as usize;
498    let m_us = info.m as usize;
499    let initial_x = if n_us > 0 {
500        std::slice::from_raw_parts(x, n_us).to_vec()
501    } else {
502        Vec::new()
503    };
504
505    let bridge = Rc::new(RefCell::new(CCallbackTnlp {
506        n: info.n,
507        m: info.m,
508        nele_jac: info.nele_jac,
509        nele_hess: info.nele_hess,
510        index_style: info.index_style,
511        x_l: info.x_l.clone(),
512        x_u: info.x_u.clone(),
513        g_l: info.g_l.clone(),
514        g_u: info.g_u.clone(),
515        initial_x,
516        eval_f: info.eval_f,
517        eval_grad_f: info.eval_grad_f,
518        eval_g: info.eval_g,
519        eval_jac_g: info.eval_jac_g,
520        eval_h: info.eval_h,
521        user_data,
522        intermediate_cb: info.intermediate_cb,
523        user_scaling: info.user_scaling.clone(),
524        final_status: None,
525        final_x: vec![0.0; n_us],
526        final_z_l: vec![0.0; n_us],
527        final_z_u: vec![0.0; n_us],
528        final_g: vec![0.0; m_us],
529        final_lambda: vec![0.0; m_us],
530        final_obj: 0.0,
531    }));
532
533    // Wire the restoration phase fresh for this solve. Without it, any
534    // line-search failure surfaces as `RestorationFailure` instead of
535    // falling back into the ℓ1-feasibility sub-IPM — exactly what the
536    // CLI driver does. `make_default_restoration_factory` is one-shot,
537    // so re-wire per `IpoptSolve` to stay correct across repeated
538    // solves on the same `IpoptProblem`. The feral config is snapshot
539    // from the now-fully-populated options so `feral_*` overrides flow
540    // into the restoration sub-IPM too.
541    let feral_cfg = feral_config_from_options(info.app.options());
542    let bff: InnerBackendFactoryFactory = Box::new(move || default_backend_factory(feral_cfg));
543    let resto_factory = make_default_restoration_factory(
544        RestoAlgorithmBuilder::new(),
545        AlgorithmBuilder::new(),
546        bff,
547    );
548    info.app.set_restoration_factory(resto_factory);
549
550    let bridge_for_solve: Rc<RefCell<dyn TNLP>> = bridge.clone();
551    let status = info.app.optimize_tnlp(bridge_for_solve);
552    info.last_solve = Some(LastSolve {
553        stats: info.app.statistics(),
554    });
555
556    let bridge_ref = bridge.borrow();
557    if !x.is_null() && n_us > 0 {
558        std::ptr::copy_nonoverlapping(bridge_ref.final_x.as_ptr(), x, n_us);
559    }
560    if !g.is_null() && m_us > 0 {
561        std::ptr::copy_nonoverlapping(bridge_ref.final_g.as_ptr(), g, m_us);
562    }
563    if !obj_val.is_null() {
564        *obj_val = bridge_ref.final_obj;
565    }
566    if !mult_g.is_null() && m_us > 0 {
567        std::ptr::copy_nonoverlapping(bridge_ref.final_lambda.as_ptr(), mult_g, m_us);
568    }
569    if !mult_x_L.is_null() && n_us > 0 {
570        std::ptr::copy_nonoverlapping(bridge_ref.final_z_l.as_ptr(), mult_x_L, n_us);
571    }
572    if !mult_x_U.is_null() && n_us > 0 {
573        std::ptr::copy_nonoverlapping(bridge_ref.final_z_u.as_ptr(), mult_x_U, n_us);
574    }
575    status as Index
576}
577
578/// Port of `SetIntermediateCallback`.
579///
580/// # Safety
581///
582/// `ipopt_problem` must be valid.
583#[no_mangle]
584pub unsafe extern "C" fn SetIntermediateCallback(
585    ipopt_problem: IpoptProblem,
586    intermediate_cb: Option<Intermediate_CB>,
587) -> Bool {
588    if ipopt_problem.is_null() {
589        return FALSE;
590    }
591    let info = &mut *ipopt_problem;
592    info.intermediate_cb = intermediate_cb;
593    TRUE
594}
595
596/// Port of `IpStdCInterface.cpp:GetIpoptCurrentIterate` (Ipopt 3.14+).
597/// Designed to be called from inside an intermediate callback to
598/// inspect `x`, the bound multipliers `z_L/z_U`, the constraint values
599/// `g`, and the constraint multipliers `lambda` at the current
600/// iterate.
601///
602/// All output buffers are optional — pass NULL to skip. `n` and `m`
603/// must match the dimensions the problem was created with; mismatched
604/// sizes cause the function to return `FALSE` without writing.
605///
606/// `scaled` is currently ignored — quantities are reported in the
607/// user TNLP's unscaled space (matching upstream Ipopt's default
608/// caller behavior when scaling is unused). Honoring `scaled` for the
609/// `gradient-based` scaler is a follow-up.
610///
611/// Returns `FALSE` when called outside an active intermediate
612/// callback (no live iterate to inspect).
613///
614/// # Safety
615///
616/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
617/// when non-NULL, must hold at least the declared length.
618#[allow(clippy::too_many_arguments)]
619#[no_mangle]
620pub unsafe extern "C" fn GetIpoptCurrentIterate(
621    ipopt_problem: IpoptProblem,
622    _scaled: Bool,
623    n: Index,
624    x: *mut Number,
625    z_l: *mut Number,
626    z_u: *mut Number,
627    m: Index,
628    g: *mut Number,
629    lambda: *mut Number,
630) -> Bool {
631    if ipopt_problem.is_null() {
632        return FALSE;
633    }
634    let info = &*ipopt_problem;
635    if n != info.n || m != info.m {
636        return FALSE;
637    }
638    let result = ip_intermediate::with_current(|ctx| {
639        let data = ctx.data.borrow();
640        let Some(curr) = data.curr.as_ref() else {
641            return false;
642        };
643        let nlp = ctx.nlp.borrow();
644        let n_us = n as usize;
645        let m_us = m as usize;
646        if !x.is_null() && n_us > 0 {
647            let full_x = nlp.lift_x_to_full(&*curr.x);
648            if full_x.len() != n_us {
649                return false;
650            }
651            std::ptr::copy_nonoverlapping(full_x.as_ptr(), x, n_us);
652        }
653        if !z_l.is_null() && n_us > 0 {
654            let full = nlp.pack_z_l_for_user(&*curr.z_l);
655            if full.len() != n_us {
656                return false;
657            }
658            std::ptr::copy_nonoverlapping(full.as_ptr(), z_l, n_us);
659        }
660        if !z_u.is_null() && n_us > 0 {
661            let full = nlp.pack_z_u_for_user(&*curr.z_u);
662            if full.len() != n_us {
663                return false;
664            }
665            std::ptr::copy_nonoverlapping(full.as_ptr(), z_u, n_us);
666        }
667        if !g.is_null() && m_us > 0 {
668            let cq = ctx.cq.borrow();
669            let full = nlp.pack_g_for_user(&*cq.curr_c(), &*cq.curr_d());
670            if full.len() != m_us {
671                return false;
672            }
673            std::ptr::copy_nonoverlapping(full.as_ptr(), g, m_us);
674        }
675        if !lambda.is_null() && m_us > 0 {
676            let full = nlp.pack_lambda_for_user(&*curr.y_c, &*curr.y_d);
677            if full.len() != m_us {
678                return false;
679            }
680            std::ptr::copy_nonoverlapping(full.as_ptr(), lambda, m_us);
681        }
682        true
683    });
684    if result.unwrap_or(false) {
685        TRUE
686    } else {
687        FALSE
688    }
689}
690
691/// Port of `IpStdCInterface.cpp:GetIpoptCurrentViolations` (Ipopt 3.14+).
692/// Same contract as [`GetIpoptCurrentIterate`]; returns `FALSE` when
693/// called outside an active intermediate callback.
694///
695/// `scaled` is currently ignored — see [`GetIpoptCurrentIterate`].
696/// Violations and complementarities are reported in the compressed
697/// algorithm-side space scattered out to full-`n`/`m`; this is the
698/// shape upstream callers consume (zero-fill for free positions /
699/// no-bound positions).
700///
701/// # Safety
702///
703/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
704/// when non-NULL, must hold at least the declared length.
705#[allow(clippy::too_many_arguments)]
706#[no_mangle]
707pub unsafe extern "C" fn GetIpoptCurrentViolations(
708    ipopt_problem: IpoptProblem,
709    _scaled: Bool,
710    n: Index,
711    x_l_violation: *mut Number,
712    x_u_violation: *mut Number,
713    compl_x_l: *mut Number,
714    compl_x_u: *mut Number,
715    grad_lag_x: *mut Number,
716    m: Index,
717    nlp_constraint_violation: *mut Number,
718    compl_g: *mut Number,
719) -> Bool {
720    if ipopt_problem.is_null() {
721        return FALSE;
722    }
723    let info = &*ipopt_problem;
724    if n != info.n || m != info.m {
725        return FALSE;
726    }
727    let result = ip_intermediate::with_current(|ctx| {
728        let data = ctx.data.borrow();
729        let Some(_curr) = data.curr.as_ref() else {
730            return false;
731        };
732        drop(data);
733        let nlp = ctx.nlp.borrow();
734        let cq = ctx.cq.borrow();
735        let n_us = n as usize;
736        let m_us = m as usize;
737        // x_L / x_U violations: scatter the compressed slack-shortfalls
738        // up to full-`n`. Upstream defines `x_L_violation_i = max(0, x_L_i
739        // - x_i)`; the algorithm tracks `slack_x_l = P_L^T x - x_L`
740        // (always non-negative at feasible iterates), so reverse the
741        // sign and clamp.
742        if !x_l_violation.is_null() && n_us > 0 {
743            let mut v = vec![0.0; n_us];
744            let slack = cq.curr_slack_x_l();
745            let z_l_full = nlp.pack_z_l_for_user(&*slack);
746            // pack_z_l_for_user scatters by the same x_L mapping; the
747            // returned vector at full-x positions holds `slack_x_l[i]`
748            // which is `x_i - x_L_i`. Clamp the *negative* part to get
749            // the violation `max(0, x_L_i - x_i)`.
750            for (i, s) in z_l_full.iter().enumerate() {
751                v[i] = (-s).max(0.0);
752            }
753            std::ptr::copy_nonoverlapping(v.as_ptr(), x_l_violation, n_us);
754        }
755        if !x_u_violation.is_null() && n_us > 0 {
756            let mut v = vec![0.0; n_us];
757            let slack = cq.curr_slack_x_u();
758            let s_full = nlp.pack_z_u_for_user(&*slack);
759            for (i, s) in s_full.iter().enumerate() {
760                v[i] = (-s).max(0.0);
761            }
762            std::ptr::copy_nonoverlapping(v.as_ptr(), x_u_violation, n_us);
763        }
764        if !compl_x_l.is_null() && n_us > 0 {
765            let v = nlp.pack_z_l_for_user(&*cq.curr_compl_x_l());
766            if v.len() != n_us {
767                return false;
768            }
769            std::ptr::copy_nonoverlapping(v.as_ptr(), compl_x_l, n_us);
770        }
771        if !compl_x_u.is_null() && n_us > 0 {
772            let v = nlp.pack_z_u_for_user(&*cq.curr_compl_x_u());
773            if v.len() != n_us {
774                return false;
775            }
776            std::ptr::copy_nonoverlapping(v.as_ptr(), compl_x_u, n_us);
777        }
778        if !grad_lag_x.is_null() && n_us > 0 {
779            let glx = cq.curr_grad_lag_x();
780            // Scatter compressed x-var → full-x via lift_x_to_full
781            // (treats `glx` as if it were an x-vector). Fixed-variable
782            // slots remain zero.
783            let full = nlp.lift_x_to_full(&*glx);
784            if full.len() != n_us {
785                return false;
786            }
787            std::ptr::copy_nonoverlapping(full.as_ptr(), grad_lag_x, n_us);
788        }
789        if !nlp_constraint_violation.is_null() && m_us > 0 {
790            // Per-row equality and range violation reconstruction in
791            // full-g coordinates is a follow-up. The scalar
792            // `curr_primal_infeasibility_max` (== `inf_pr` reported in
793            // `IterStats`) is the outer summary; populate per-row
794            // detail as a future refinement and zero-fill for now.
795            let zero = vec![0.0; m_us];
796            std::ptr::copy_nonoverlapping(zero.as_ptr(), nlp_constraint_violation, m_us);
797        }
798        if !compl_g.is_null() && m_us > 0 {
799            // Per-row constraint complementarity (`v_L .* s_L` /
800            // `v_U .* s_U` mapped back to full-g) is also a follow-up.
801            let zero = vec![0.0; m_us];
802            std::ptr::copy_nonoverlapping(zero.as_ptr(), compl_g, m_us);
803        }
804        true
805    });
806    if result.unwrap_or(false) {
807        TRUE
808    } else {
809        FALSE
810    }
811}
812
813/// Port of `IpStdCInterface.cpp:GetIpoptVersion` (Ipopt 3.14.18+).
814/// Writes the pounce crate's `major.minor.patch` into the buffers.
815/// Any pointer may be NULL to skip that component.
816///
817/// # Safety
818///
819/// Each non-NULL pointer must point at a writable `int`.
820#[no_mangle]
821pub unsafe extern "C" fn GetIpoptVersion(
822    major: *mut c_int,
823    minor: *mut c_int,
824    release: *mut c_int,
825) {
826    // Read from Cargo at compile time so the symbol always matches the
827    // shipped binary. `unwrap_or(0)` keeps the function infallible if a
828    // component is missing from the manifest (shouldn't happen in
829    // practice — workspace manifest requires SemVer triples).
830    let (mj, mn, pt) = parse_pkg_version(env!("CARGO_PKG_VERSION"));
831    if !major.is_null() {
832        *major = mj;
833    }
834    if !minor.is_null() {
835        *minor = mn;
836    }
837    if !release.is_null() {
838        *release = pt;
839    }
840}
841
842fn parse_pkg_version(v: &str) -> (c_int, c_int, c_int) {
843    let mut it = v.split('.').map(|s| s.parse::<c_int>().unwrap_or(0));
844    (
845        it.next().unwrap_or(0),
846        it.next().unwrap_or(0),
847        it.next().unwrap_or(0),
848    )
849}
850
851// ----------------------------------------------------------------------
852// Pounce extensions: post-solve statistics accessors.
853//
854// Convenience accessors not present in upstream Ipopt's C API. Valid
855// only after [`IpoptSolve`] has returned; calling them on a
856// never-solved problem yields zero. They expose the same
857// `SolveStatistics` data the Rust API surfaces via
858// [`IpoptApplication::statistics`].
859// ----------------------------------------------------------------------
860
861/// Number of IPM iterations in the most recent solve, or `0` if the
862/// problem has not been solved yet.
863///
864/// # Safety
865///
866/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
867#[no_mangle]
868pub unsafe extern "C" fn GetIpoptIterCount(ipopt_problem: IpoptProblem) -> Index {
869    last_stat(ipopt_problem, |s| s.iteration_count).unwrap_or(0)
870}
871
872/// Wall-clock solve time in seconds for the most recent solve, or
873/// `0.0` if the problem has not been solved yet.
874///
875/// # Safety
876///
877/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
878#[no_mangle]
879pub unsafe extern "C" fn GetIpoptSolveTime(ipopt_problem: IpoptProblem) -> Number {
880    last_stat(ipopt_problem, |s| s.total_wallclock_time_secs).unwrap_or(0.0)
881}
882
883/// Final primal infeasibility (max constraint violation) for the most
884/// recent solve.
885///
886/// # Safety
887///
888/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
889#[no_mangle]
890pub unsafe extern "C" fn GetIpoptPrimalInf(ipopt_problem: IpoptProblem) -> Number {
891    last_stat(ipopt_problem, |s| s.final_constr_viol).unwrap_or(0.0)
892}
893
894/// Final dual infeasibility (max gradient-of-Lagrangian norm) for the
895/// most recent solve.
896///
897/// # Safety
898///
899/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
900#[no_mangle]
901pub unsafe extern "C" fn GetIpoptDualInf(ipopt_problem: IpoptProblem) -> Number {
902    last_stat(ipopt_problem, |s| s.final_dual_inf).unwrap_or(0.0)
903}
904
905/// Final complementarity error for the most recent solve.
906///
907/// # Safety
908///
909/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
910#[no_mangle]
911pub unsafe extern "C" fn GetIpoptComplInf(ipopt_problem: IpoptProblem) -> Number {
912    last_stat(ipopt_problem, |s| s.final_compl).unwrap_or(0.0)
913}
914
915unsafe fn last_stat<T, F>(ipopt_problem: IpoptProblem, f: F) -> Option<T>
916where
917    F: FnOnce(&SolveStatistics) -> T,
918{
919    if ipopt_problem.is_null() {
920        return None;
921    }
922    (*ipopt_problem).last_solve.as_ref().map(|ls| f(&ls.stats))
923}
924
925/// Adapter that bridges the user-supplied C callback table to the
926/// in-crate [`TNLP`] trait. Mirrors `Interfaces/IpStdInterfaceTNLP.cpp`
927/// (`StdInterfaceTNLP`); each TNLP method forwards to the matching
928/// `Eval_*_CB` and propagates `false` returns up so the algorithm
929/// layer can map them to `Invalid_Number_Detected`.
930///
931/// Holds a snapshot of bounds and the initial `x`. After `optimize_tnlp`
932/// finishes, `finalize_solution` is called by the algorithm layer; the
933/// adapter records the final iterate in `final_*` fields, which the
934/// outer [`IpoptSolve`] copies back into the caller's buffers.
935pub(crate) struct CCallbackTnlp {
936    pub(crate) n: Index,
937    pub(crate) m: Index,
938    pub(crate) nele_jac: Index,
939    pub(crate) nele_hess: Index,
940    pub(crate) index_style: Index,
941    pub(crate) x_l: Vec<Number>,
942    pub(crate) x_u: Vec<Number>,
943    pub(crate) g_l: Vec<Number>,
944    pub(crate) g_u: Vec<Number>,
945    pub(crate) initial_x: Vec<Number>,
946    pub(crate) eval_f: Option<Eval_F_CB>,
947    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
948    pub(crate) eval_g: Option<Eval_G_CB>,
949    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
950    pub(crate) eval_h: Option<Eval_H_CB>,
951    pub(crate) user_data: *mut c_void,
952    /// User-installed intermediate callback, copied at solve time so the
953    /// TNLP-trait `intermediate_callback` impl can forward through to it.
954    pub(crate) intermediate_cb: Option<Intermediate_CB>,
955    /// Snapshot of user-provided scaling captured at solve time.
956    pub(crate) user_scaling: Option<UserScaling>,
957    pub(crate) final_status: Option<pounce_nlp::alg_types::SolverReturn>,
958    pub(crate) final_x: Vec<Number>,
959    pub(crate) final_z_l: Vec<Number>,
960    pub(crate) final_z_u: Vec<Number>,
961    pub(crate) final_g: Vec<Number>,
962    pub(crate) final_lambda: Vec<Number>,
963    pub(crate) final_obj: Number,
964}
965
966impl TNLP for CCallbackTnlp {
967    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
968        Some(NlpInfo {
969            n: self.n as pounce_common::types::Index,
970            m: self.m as pounce_common::types::Index,
971            nnz_jac_g: self.nele_jac as pounce_common::types::Index,
972            nnz_h_lag: self.nele_hess as pounce_common::types::Index,
973            index_style: if self.index_style == 1 {
974                IndexStyle::Fortran
975            } else {
976                IndexStyle::C
977            },
978        })
979    }
980
981    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
982        if !self.x_l.is_empty() {
983            b.x_l.copy_from_slice(&self.x_l);
984        }
985        if !self.x_u.is_empty() {
986            b.x_u.copy_from_slice(&self.x_u);
987        }
988        if !self.g_l.is_empty() {
989            b.g_l.copy_from_slice(&self.g_l);
990        }
991        if !self.g_u.is_empty() {
992            b.g_u.copy_from_slice(&self.g_u);
993        }
994        true
995    }
996
997    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
998        if !self.initial_x.is_empty() {
999            sp.x.copy_from_slice(&self.initial_x);
1000        }
1001        true
1002    }
1003
1004    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
1005        let Some(s) = self.user_scaling.as_ref() else {
1006            return false;
1007        };
1008        *req.obj_scaling = s.obj_scaling;
1009        if let Some(x) = s.x_scaling.as_ref() {
1010            if x.len() == req.x_scaling.len() {
1011                req.x_scaling.copy_from_slice(x);
1012                *req.use_x_scaling = true;
1013            }
1014        } else {
1015            *req.use_x_scaling = false;
1016        }
1017        if let Some(g) = s.g_scaling.as_ref() {
1018            if g.len() == req.g_scaling.len() {
1019                req.g_scaling.copy_from_slice(g);
1020                *req.use_g_scaling = true;
1021            }
1022        } else {
1023            *req.use_g_scaling = false;
1024        }
1025        true
1026    }
1027
1028    fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
1029        let cb = self.eval_f?;
1030        let mut obj = 0.0;
1031        let ok = unsafe {
1032            cb(
1033                self.n,
1034                x.as_ptr() as *mut Number,
1035                if new_x { TRUE } else { FALSE },
1036                &mut obj,
1037                self.user_data,
1038            )
1039        };
1040        if ok != FALSE {
1041            Some(obj)
1042        } else {
1043            None
1044        }
1045    }
1046
1047    fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
1048        let Some(cb) = self.eval_grad_f else {
1049            return false;
1050        };
1051        let ok = unsafe {
1052            cb(
1053                self.n,
1054                x.as_ptr() as *mut Number,
1055                if new_x { TRUE } else { FALSE },
1056                grad_f.as_mut_ptr(),
1057                self.user_data,
1058            )
1059        };
1060        ok != FALSE
1061    }
1062
1063    fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
1064        if self.m == 0 {
1065            return true;
1066        }
1067        let Some(cb) = self.eval_g else {
1068            return false;
1069        };
1070        let ok = unsafe {
1071            cb(
1072                self.n,
1073                x.as_ptr() as *mut Number,
1074                if new_x { TRUE } else { FALSE },
1075                self.m,
1076                g.as_mut_ptr(),
1077                self.user_data,
1078            )
1079        };
1080        ok != FALSE
1081    }
1082
1083    fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
1084        if self.m == 0 || self.nele_jac == 0 {
1085            return true;
1086        }
1087        let Some(cb) = self.eval_jac_g else {
1088            return false;
1089        };
1090        let x_ptr = x
1091            .map(|s| s.as_ptr() as *mut Number)
1092            .unwrap_or(std::ptr::null_mut());
1093        let ok = match mode {
1094            SparsityRequest::Structure { irow, jcol } => unsafe {
1095                cb(
1096                    self.n,
1097                    x_ptr,
1098                    if new_x { TRUE } else { FALSE },
1099                    self.m,
1100                    self.nele_jac,
1101                    irow.as_mut_ptr(),
1102                    jcol.as_mut_ptr(),
1103                    std::ptr::null_mut(),
1104                    self.user_data,
1105                )
1106            },
1107            SparsityRequest::Values { values } => unsafe {
1108                cb(
1109                    self.n,
1110                    x_ptr,
1111                    if new_x { TRUE } else { FALSE },
1112                    self.m,
1113                    self.nele_jac,
1114                    std::ptr::null_mut(),
1115                    std::ptr::null_mut(),
1116                    values.as_mut_ptr(),
1117                    self.user_data,
1118                )
1119            },
1120        };
1121        ok != FALSE
1122    }
1123
1124    fn eval_h(
1125        &mut self,
1126        x: Option<&[Number]>,
1127        new_x: bool,
1128        obj_factor: Number,
1129        lambda: Option<&[Number]>,
1130        new_lambda: bool,
1131        mode: SparsityRequest<'_>,
1132    ) -> bool {
1133        let Some(cb) = self.eval_h else {
1134            return false;
1135        };
1136        if self.nele_hess == 0 {
1137            return true;
1138        }
1139        let x_ptr = x
1140            .map(|s| s.as_ptr() as *mut Number)
1141            .unwrap_or(std::ptr::null_mut());
1142        let lambda_ptr = lambda
1143            .map(|s| s.as_ptr() as *mut Number)
1144            .unwrap_or(std::ptr::null_mut());
1145        let ok = match mode {
1146            SparsityRequest::Structure { irow, jcol } => unsafe {
1147                cb(
1148                    self.n,
1149                    x_ptr,
1150                    if new_x { TRUE } else { FALSE },
1151                    obj_factor,
1152                    self.m,
1153                    lambda_ptr,
1154                    if new_lambda { TRUE } else { FALSE },
1155                    self.nele_hess,
1156                    irow.as_mut_ptr(),
1157                    jcol.as_mut_ptr(),
1158                    std::ptr::null_mut(),
1159                    self.user_data,
1160                )
1161            },
1162            SparsityRequest::Values { values } => unsafe {
1163                cb(
1164                    self.n,
1165                    x_ptr,
1166                    if new_x { TRUE } else { FALSE },
1167                    obj_factor,
1168                    self.m,
1169                    lambda_ptr,
1170                    if new_lambda { TRUE } else { FALSE },
1171                    self.nele_hess,
1172                    std::ptr::null_mut(),
1173                    std::ptr::null_mut(),
1174                    values.as_mut_ptr(),
1175                    self.user_data,
1176                )
1177            },
1178        };
1179        ok != FALSE
1180    }
1181
1182    fn intermediate_callback(
1183        &mut self,
1184        stats: pounce_nlp::tnlp::IterStats,
1185        _ip_data: &IpoptData,
1186        _ip_cq: &IpoptCq,
1187    ) -> bool {
1188        let Some(cb) = self.intermediate_cb else {
1189            return true;
1190        };
1191        let ok = unsafe {
1192            cb(
1193                stats.mode as Index,
1194                stats.iter as Index,
1195                stats.obj_value,
1196                stats.inf_pr,
1197                stats.inf_du,
1198                stats.mu,
1199                stats.d_norm,
1200                stats.regularization_size,
1201                stats.alpha_du,
1202                stats.alpha_pr,
1203                stats.ls_trials as Index,
1204                self.user_data,
1205            )
1206        };
1207        ok != FALSE
1208    }
1209
1210    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
1211        self.final_status = Some(sol.status);
1212        if !sol.x.is_empty() {
1213            self.final_x.copy_from_slice(sol.x);
1214        }
1215        if !sol.z_l.is_empty() {
1216            self.final_z_l.copy_from_slice(sol.z_l);
1217        }
1218        if !sol.z_u.is_empty() {
1219            self.final_z_u.copy_from_slice(sol.z_u);
1220        }
1221        if !sol.g.is_empty() {
1222            self.final_g.copy_from_slice(sol.g);
1223        }
1224        if !sol.lambda.is_empty() {
1225            self.final_lambda.copy_from_slice(sol.lambda);
1226        }
1227        self.final_obj = sol.obj_value;
1228    }
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233    use super::*;
1234    use std::ffi::CString;
1235
1236    unsafe extern "C" fn dummy_eval_f(
1237        _n: Index,
1238        _x: *const Number,
1239        _new_x: Bool,
1240        _obj_value: *mut Number,
1241        _user_data: *mut c_void,
1242    ) -> Bool {
1243        TRUE
1244    }
1245    unsafe extern "C" fn dummy_eval_grad_f(
1246        _n: Index,
1247        _x: *const Number,
1248        _new_x: Bool,
1249        _grad_f: *mut Number,
1250        _user_data: *mut c_void,
1251    ) -> Bool {
1252        TRUE
1253    }
1254
1255    fn create_unconstrained() -> IpoptProblem {
1256        let xl = [-1.0; 4];
1257        let xu = [1.0; 4];
1258        unsafe {
1259            CreateIpoptProblem(
1260                4,
1261                xl.as_ptr(),
1262                xu.as_ptr(),
1263                0,
1264                std::ptr::null(),
1265                std::ptr::null(),
1266                0,
1267                10,
1268                0,
1269                Some(dummy_eval_f),
1270                None,
1271                Some(dummy_eval_grad_f),
1272                None,
1273                None,
1274            )
1275        }
1276    }
1277
1278    #[test]
1279    fn create_succeeds_for_unconstrained_problem() {
1280        let p = create_unconstrained();
1281        assert!(!p.is_null());
1282        unsafe { FreeIpoptProblem(p) };
1283    }
1284
1285    #[test]
1286    fn create_returns_null_on_missing_required_callbacks() {
1287        let xl = [-1.0; 4];
1288        let xu = [1.0; 4];
1289        let p = unsafe {
1290            CreateIpoptProblem(
1291                4,
1292                xl.as_ptr(),
1293                xu.as_ptr(),
1294                0,
1295                std::ptr::null(),
1296                std::ptr::null(),
1297                0,
1298                10,
1299                0,
1300                None, // missing eval_f
1301                None,
1302                Some(dummy_eval_grad_f),
1303                None,
1304                None,
1305            )
1306        };
1307        assert!(p.is_null());
1308    }
1309
1310    #[test]
1311    fn create_returns_null_on_negative_n() {
1312        let p = unsafe {
1313            CreateIpoptProblem(
1314                -1,
1315                std::ptr::null(),
1316                std::ptr::null(),
1317                0,
1318                std::ptr::null(),
1319                std::ptr::null(),
1320                0,
1321                10,
1322                0,
1323                Some(dummy_eval_f),
1324                None,
1325                Some(dummy_eval_grad_f),
1326                None,
1327                None,
1328            )
1329        };
1330        assert!(p.is_null());
1331    }
1332
1333    #[test]
1334    fn create_returns_null_on_invalid_index_style() {
1335        let xl = [0.0; 1];
1336        let xu = [1.0; 1];
1337        let p = unsafe {
1338            CreateIpoptProblem(
1339                1,
1340                xl.as_ptr(),
1341                xu.as_ptr(),
1342                0,
1343                std::ptr::null(),
1344                std::ptr::null(),
1345                0,
1346                1,
1347                2, // valid values are 0 and 1
1348                Some(dummy_eval_f),
1349                None,
1350                Some(dummy_eval_grad_f),
1351                None,
1352                None,
1353            )
1354        };
1355        assert!(p.is_null());
1356    }
1357
1358    #[test]
1359    fn add_int_option_forwards_to_application() {
1360        let p = create_unconstrained();
1361        let key = CString::new("print_level").unwrap();
1362        let ok = unsafe { AddIpoptIntOption(p, key.as_ptr(), 5) };
1363        assert_eq!(ok, TRUE);
1364        let info = unsafe { &*p };
1365        let (level, found) = info
1366            .app
1367            .options()
1368            .get_integer_value("print_level", "")
1369            .unwrap();
1370        assert!(found);
1371        assert_eq!(level, 5);
1372        unsafe { FreeIpoptProblem(p) };
1373    }
1374
1375    #[test]
1376    fn add_str_option_with_invalid_key_returns_false() {
1377        let p = create_unconstrained();
1378        let key = CString::new("totally_unknown_option").unwrap();
1379        let val = CString::new("yes").unwrap();
1380        let ok = unsafe { AddIpoptStrOption(p, key.as_ptr(), val.as_ptr()) };
1381        assert_eq!(ok, FALSE);
1382        unsafe { FreeIpoptProblem(p) };
1383    }
1384
1385    #[test]
1386    fn add_options_on_null_problem_returns_false() {
1387        let key = CString::new("print_level").unwrap();
1388        let v = CString::new("yes").unwrap();
1389        unsafe {
1390            assert_eq!(
1391                AddIpoptIntOption(std::ptr::null_mut(), key.as_ptr(), 5),
1392                FALSE
1393            );
1394            assert_eq!(
1395                AddIpoptNumOption(std::ptr::null_mut(), key.as_ptr(), 1.0),
1396                FALSE
1397            );
1398            assert_eq!(
1399                AddIpoptStrOption(std::ptr::null_mut(), key.as_ptr(), v.as_ptr()),
1400                FALSE
1401            );
1402        }
1403    }
1404
1405    unsafe extern "C" fn dummy_intermediate(
1406        _alg_mod: Index,
1407        _iter_count: Index,
1408        _obj_value: Number,
1409        _inf_pr: Number,
1410        _inf_du: Number,
1411        _mu: Number,
1412        _d_norm: Number,
1413        _regularization_size: Number,
1414        _alpha_du: Number,
1415        _alpha_pr: Number,
1416        _ls_trials: Index,
1417        _user_data: *mut c_void,
1418    ) -> Bool {
1419        TRUE
1420    }
1421
1422    #[test]
1423    fn set_intermediate_callback_stores_pointer() {
1424        let p = create_unconstrained();
1425        let ok = unsafe { SetIntermediateCallback(p, Some(dummy_intermediate)) };
1426        assert_eq!(ok, TRUE);
1427        let info = unsafe { &*p };
1428        assert!(info.intermediate_cb.is_some());
1429        unsafe { FreeIpoptProblem(p) };
1430    }
1431
1432    #[test]
1433    fn solve_returns_internal_error_on_null_problem() {
1434        let rc = unsafe {
1435            IpoptSolve(
1436                std::ptr::null_mut(),
1437                std::ptr::null_mut(),
1438                std::ptr::null_mut(),
1439                std::ptr::null_mut(),
1440                std::ptr::null_mut(),
1441                std::ptr::null_mut(),
1442                std::ptr::null_mut(),
1443                std::ptr::null_mut(),
1444            )
1445        };
1446        assert_eq!(rc, -199);
1447    }
1448
1449    #[test]
1450    fn free_null_is_safe() {
1451        unsafe { FreeIpoptProblem(std::ptr::null_mut()) };
1452    }
1453
1454    // ---- End-to-end bridge: 1-D unconstrained quadratic ----
1455    //
1456    // f(x) = (x - 2)^2, no bounds, no constraints. Newton driver
1457    // converges in one step.
1458
1459    unsafe extern "C" fn quad_eval_f(
1460        _n: Index,
1461        x: *const Number,
1462        _new_x: Bool,
1463        obj_value: *mut Number,
1464        _user_data: *mut c_void,
1465    ) -> Bool {
1466        let v = *x.offset(0);
1467        *obj_value = (v - 2.0) * (v - 2.0);
1468        TRUE
1469    }
1470    unsafe extern "C" fn quad_eval_grad_f(
1471        _n: Index,
1472        x: *const Number,
1473        _new_x: Bool,
1474        grad: *mut Number,
1475        _user_data: *mut c_void,
1476    ) -> Bool {
1477        let v = *x.offset(0);
1478        *grad.offset(0) = 2.0 * (v - 2.0);
1479        TRUE
1480    }
1481    unsafe extern "C" fn quad_eval_h(
1482        _n: Index,
1483        _x: *const Number,
1484        _new_x: Bool,
1485        obj_factor: Number,
1486        _m: Index,
1487        _lambda: *const Number,
1488        _new_lambda: Bool,
1489        _nele_hess: Index,
1490        irow: *mut Index,
1491        jcol: *mut Index,
1492        values: *mut Number,
1493        _user_data: *mut c_void,
1494    ) -> Bool {
1495        if !irow.is_null() && !jcol.is_null() && values.is_null() {
1496            *irow.offset(0) = 0;
1497            *jcol.offset(0) = 0;
1498        } else if irow.is_null() && jcol.is_null() && !values.is_null() {
1499            *values.offset(0) = 2.0 * obj_factor;
1500        } else {
1501            return FALSE;
1502        }
1503        TRUE
1504    }
1505
1506    #[test]
1507    fn solve_drives_unconstrained_quadratic_through_bridge() {
1508        // Bounds wide open (kappa1 push won't move us off 0.0 since
1509        // |0| < 1e19, but the Newton step lands us at 2.0 anyway).
1510        let xl = [-1.0e20];
1511        let xu = [1.0e20];
1512        let p = unsafe {
1513            CreateIpoptProblem(
1514                1,
1515                xl.as_ptr(),
1516                xu.as_ptr(),
1517                0,
1518                std::ptr::null(),
1519                std::ptr::null(),
1520                0,
1521                1,
1522                0,
1523                Some(quad_eval_f),
1524                None,
1525                Some(quad_eval_grad_f),
1526                None,
1527                Some(quad_eval_h),
1528            )
1529        };
1530        assert!(!p.is_null());
1531        let mut x = [0.0_f64];
1532        let mut obj = 0.0_f64;
1533        let rc = unsafe {
1534            IpoptSolve(
1535                p,
1536                x.as_mut_ptr(),
1537                std::ptr::null_mut(),
1538                &mut obj,
1539                std::ptr::null_mut(),
1540                std::ptr::null_mut(),
1541                std::ptr::null_mut(),
1542                std::ptr::null_mut(),
1543            )
1544        };
1545        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
1546        assert!((x[0] - 2.0).abs() < 1e-6, "x[0] = {}", x[0]);
1547        assert!(obj.abs() < 1e-10, "obj = {}", obj);
1548        unsafe { FreeIpoptProblem(p) };
1549    }
1550
1551    #[test]
1552    fn solve_invalid_problem_definition_when_x_null() {
1553        let p = create_unconstrained();
1554        let rc = unsafe {
1555            IpoptSolve(
1556                p,
1557                std::ptr::null_mut(), // x null but n > 0
1558                std::ptr::null_mut(),
1559                std::ptr::null_mut(),
1560                std::ptr::null_mut(),
1561                std::ptr::null_mut(),
1562                std::ptr::null_mut(),
1563                std::ptr::null_mut(),
1564            )
1565        };
1566        assert_eq!(
1567            rc,
1568            ApplicationReturnStatus::InvalidProblemDefinition as Index
1569        );
1570        unsafe { FreeIpoptProblem(p) };
1571    }
1572
1573    // ---- New entry points (issue #19) ----
1574
1575    #[test]
1576    fn get_version_writes_pkg_version() {
1577        let (mut mj, mut mn, mut pt) = (-1, -1, -1);
1578        unsafe { GetIpoptVersion(&mut mj, &mut mn, &mut pt) };
1579        let expected = parse_pkg_version(env!("CARGO_PKG_VERSION"));
1580        assert_eq!((mj, mn, pt), expected);
1581    }
1582
1583    #[test]
1584    fn get_version_tolerates_null_buffers() {
1585        // None of these should crash.
1586        unsafe {
1587            GetIpoptVersion(
1588                std::ptr::null_mut(),
1589                std::ptr::null_mut(),
1590                std::ptr::null_mut(),
1591            )
1592        };
1593    }
1594
1595    #[test]
1596    fn set_scaling_stores_user_supplied_arrays() {
1597        let p = create_unconstrained();
1598        let xs = [2.0, 3.0, 4.0, 5.0];
1599        let ok = unsafe { SetIpoptProblemScaling(p, 7.0, xs.as_ptr(), std::ptr::null()) };
1600        assert_eq!(ok, TRUE);
1601        let info = unsafe { &*p };
1602        let s = info.user_scaling.as_ref().unwrap();
1603        assert_eq!(s.obj_scaling, 7.0);
1604        assert_eq!(s.x_scaling.as_deref(), Some(&xs[..]));
1605        assert!(s.g_scaling.is_none());
1606        unsafe { FreeIpoptProblem(p) };
1607    }
1608
1609    #[test]
1610    fn set_scaling_on_null_problem_returns_false() {
1611        let ok = unsafe {
1612            SetIpoptProblemScaling(
1613                std::ptr::null_mut(),
1614                1.0,
1615                std::ptr::null(),
1616                std::ptr::null(),
1617            )
1618        };
1619        assert_eq!(ok, FALSE);
1620    }
1621
1622    #[test]
1623    fn open_output_file_writes_and_attaches_journal() {
1624        let p = create_unconstrained();
1625        let dir = std::env::temp_dir().join("pounce-cinterface-test");
1626        let _ = std::fs::create_dir_all(&dir);
1627        let path = dir.join("output.log");
1628        let cstr = CString::new(path.to_string_lossy().as_bytes()).unwrap();
1629        let ok = unsafe { OpenIpoptOutputFile(p, cstr.as_ptr(), 5) };
1630        assert_eq!(ok, TRUE);
1631        // Option should be reflected in the app.
1632        let info = unsafe { &*p };
1633        let (level, found) = info
1634            .app
1635            .options()
1636            .get_integer_value("file_print_level", "")
1637            .unwrap();
1638        assert!(found);
1639        assert_eq!(level, 5);
1640        unsafe { FreeIpoptProblem(p) };
1641        let _ = std::fs::remove_file(&path);
1642    }
1643
1644    #[test]
1645    fn open_output_file_with_null_inputs_returns_false() {
1646        let key = CString::new("nope").unwrap();
1647        unsafe {
1648            assert_eq!(
1649                OpenIpoptOutputFile(std::ptr::null_mut(), key.as_ptr(), 0),
1650                FALSE
1651            );
1652        }
1653        let p = create_unconstrained();
1654        unsafe {
1655            assert_eq!(OpenIpoptOutputFile(p, std::ptr::null(), 0), FALSE);
1656            FreeIpoptProblem(p);
1657        }
1658    }
1659
1660    #[test]
1661    fn get_current_iterate_returns_false_outside_callback() {
1662        let p = create_unconstrained();
1663        let rc = unsafe {
1664            GetIpoptCurrentIterate(
1665                p,
1666                FALSE,
1667                0,
1668                std::ptr::null_mut(),
1669                std::ptr::null_mut(),
1670                std::ptr::null_mut(),
1671                0,
1672                std::ptr::null_mut(),
1673                std::ptr::null_mut(),
1674            )
1675        };
1676        assert_eq!(rc, FALSE);
1677        unsafe { FreeIpoptProblem(p) };
1678    }
1679
1680    #[test]
1681    fn get_current_violations_returns_false_outside_callback() {
1682        let p = create_unconstrained();
1683        let rc = unsafe {
1684            GetIpoptCurrentViolations(
1685                p,
1686                FALSE,
1687                0,
1688                std::ptr::null_mut(),
1689                std::ptr::null_mut(),
1690                std::ptr::null_mut(),
1691                std::ptr::null_mut(),
1692                std::ptr::null_mut(),
1693                0,
1694                std::ptr::null_mut(),
1695                std::ptr::null_mut(),
1696            )
1697        };
1698        assert_eq!(rc, FALSE);
1699        unsafe { FreeIpoptProblem(p) };
1700    }
1701
1702    #[test]
1703    fn post_solve_stats_zero_before_solve() {
1704        let p = create_unconstrained();
1705        unsafe {
1706            assert_eq!(GetIpoptIterCount(p), 0);
1707            assert_eq!(GetIpoptSolveTime(p), 0.0);
1708            assert_eq!(GetIpoptPrimalInf(p), 0.0);
1709            assert_eq!(GetIpoptDualInf(p), 0.0);
1710            assert_eq!(GetIpoptComplInf(p), 0.0);
1711            FreeIpoptProblem(p);
1712        }
1713    }
1714
1715    #[test]
1716    fn post_solve_stats_populated_after_solve() {
1717        // Reuse the same quadratic as the end-to-end solve test.
1718        let xl = [-1.0e20];
1719        let xu = [1.0e20];
1720        let p = unsafe {
1721            CreateIpoptProblem(
1722                1,
1723                xl.as_ptr(),
1724                xu.as_ptr(),
1725                0,
1726                std::ptr::null(),
1727                std::ptr::null(),
1728                0,
1729                1,
1730                0,
1731                Some(quad_eval_f),
1732                None,
1733                Some(quad_eval_grad_f),
1734                None,
1735                Some(quad_eval_h),
1736            )
1737        };
1738        let mut x = [0.0_f64];
1739        let mut obj = 0.0_f64;
1740        let rc = unsafe {
1741            IpoptSolve(
1742                p,
1743                x.as_mut_ptr(),
1744                std::ptr::null_mut(),
1745                &mut obj,
1746                std::ptr::null_mut(),
1747                std::ptr::null_mut(),
1748                std::ptr::null_mut(),
1749                std::ptr::null_mut(),
1750            )
1751        };
1752        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
1753        // After a successful solve, iter count is recorded (>= 0) and
1754        // wall time is non-negative; primal/dual/compl norms exist.
1755        unsafe {
1756            assert!(GetIpoptIterCount(p) >= 0);
1757            assert!(GetIpoptSolveTime(p) >= 0.0);
1758            assert!(GetIpoptPrimalInf(p).is_finite());
1759            assert!(GetIpoptDualInf(p).is_finite());
1760            assert!(GetIpoptComplInf(p).is_finite());
1761            FreeIpoptProblem(p);
1762        }
1763    }
1764
1765    // --- Intermediate-callback wiring (issue #19, follow-up) ---
1766    //
1767    // The callback only fires on the IPM path (`optimize_constrained`).
1768    // Unconstrained problems short-circuit through the Newton driver,
1769    // so these tests use a single-inequality problem to force the IPM.
1770
1771    unsafe extern "C" fn cb_quad_eval_g(
1772        _n: Index,
1773        x: *const Number,
1774        _new_x: Bool,
1775        _m: Index,
1776        g: *mut Number,
1777        _user_data: *mut c_void,
1778    ) -> Bool {
1779        *g.offset(0) = *x.offset(0);
1780        TRUE
1781    }
1782    unsafe extern "C" fn cb_quad_eval_jac_g(
1783        _n: Index,
1784        _x: *const Number,
1785        _new_x: Bool,
1786        _m: Index,
1787        nele_jac: Index,
1788        irow: *mut Index,
1789        jcol: *mut Index,
1790        values: *mut Number,
1791        _user_data: *mut c_void,
1792    ) -> Bool {
1793        assert_eq!(nele_jac, 1);
1794        if !irow.is_null() {
1795            *irow.offset(0) = 0;
1796            *jcol.offset(0) = 0;
1797        }
1798        if !values.is_null() {
1799            *values.offset(0) = 1.0;
1800        }
1801        TRUE
1802    }
1803    unsafe extern "C" fn cb_quad_eval_h(
1804        _n: Index,
1805        _x: *const Number,
1806        _new_x: Bool,
1807        obj_factor: Number,
1808        _m: Index,
1809        _lambda: *const Number,
1810        _new_lambda: Bool,
1811        _nele_hess: Index,
1812        irow: *mut Index,
1813        jcol: *mut Index,
1814        values: *mut Number,
1815        _user_data: *mut c_void,
1816    ) -> Bool {
1817        if !irow.is_null() {
1818            *irow.offset(0) = 0;
1819            *jcol.offset(0) = 0;
1820        }
1821        if !values.is_null() {
1822            *values.offset(0) = 2.0 * obj_factor;
1823        }
1824        TRUE
1825    }
1826
1827    fn create_callback_test_problem() -> IpoptProblem {
1828        // min (x - 2)^2  s.t.  -10 <= x <= 10 (single inequality).
1829        let xl = [-1.0e20];
1830        let xu = [1.0e20];
1831        let gl = [-10.0];
1832        let gu = [10.0];
1833        unsafe {
1834            CreateIpoptProblem(
1835                1,
1836                xl.as_ptr(),
1837                xu.as_ptr(),
1838                1,
1839                gl.as_ptr(),
1840                gu.as_ptr(),
1841                1,
1842                1,
1843                0,
1844                Some(quad_eval_f),
1845                Some(cb_quad_eval_g),
1846                Some(quad_eval_grad_f),
1847                Some(cb_quad_eval_jac_g),
1848                Some(cb_quad_eval_h),
1849            )
1850        }
1851    }
1852
1853    static CB_ITER_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1854    static CB_LAST_ITER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
1855    static CB_INSPECTOR_OK: std::sync::atomic::AtomicBool =
1856        std::sync::atomic::AtomicBool::new(false);
1857
1858    unsafe extern "C" fn counting_cb(
1859        _alg_mod: Index,
1860        iter_count: Index,
1861        _obj_value: Number,
1862        _inf_pr: Number,
1863        _inf_du: Number,
1864        _mu: Number,
1865        _d_norm: Number,
1866        _regularization_size: Number,
1867        _alpha_du: Number,
1868        _alpha_pr: Number,
1869        _ls_trials: Index,
1870        user_data: *mut c_void,
1871    ) -> Bool {
1872        CB_ITER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1873        CB_LAST_ITER.store(iter_count, std::sync::atomic::Ordering::SeqCst);
1874        // user_data carries the IpoptProblem so we can exercise the
1875        // inspector from inside the callback.
1876        let problem = user_data as IpoptProblem;
1877        let mut x = [0.0_f64];
1878        let rc = GetIpoptCurrentIterate(
1879            problem,
1880            FALSE,
1881            1,
1882            x.as_mut_ptr(),
1883            std::ptr::null_mut(),
1884            std::ptr::null_mut(),
1885            1,
1886            std::ptr::null_mut(),
1887            std::ptr::null_mut(),
1888        );
1889        if rc == TRUE && x[0].is_finite() {
1890            CB_INSPECTOR_OK.store(true, std::sync::atomic::Ordering::SeqCst);
1891        }
1892        TRUE
1893    }
1894
1895    #[test]
1896    fn intermediate_callback_fires_per_iteration_and_inspector_reads_x() {
1897        CB_ITER_COUNTER.store(0, std::sync::atomic::Ordering::SeqCst);
1898        CB_LAST_ITER.store(-1, std::sync::atomic::Ordering::SeqCst);
1899        CB_INSPECTOR_OK.store(false, std::sync::atomic::Ordering::SeqCst);
1900
1901        let p = create_callback_test_problem();
1902        assert!(!p.is_null());
1903        let ok = unsafe { SetIntermediateCallback(p, Some(counting_cb)) };
1904        assert_eq!(ok, TRUE);
1905        let mut x = [0.0_f64];
1906        let mut obj = 0.0_f64;
1907        let rc = unsafe {
1908            IpoptSolve(
1909                p,
1910                x.as_mut_ptr(),
1911                std::ptr::null_mut(),
1912                &mut obj,
1913                std::ptr::null_mut(),
1914                std::ptr::null_mut(),
1915                std::ptr::null_mut(),
1916                p as *mut c_void,
1917            )
1918        };
1919        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
1920        // At least the iter-0 fire happened, plus one per accepted step.
1921        let n_fires = CB_ITER_COUNTER.load(std::sync::atomic::Ordering::SeqCst);
1922        assert!(n_fires >= 2, "callback fired {n_fires} times, want >=2");
1923        assert!(
1924            CB_LAST_ITER.load(std::sync::atomic::Ordering::SeqCst) >= 1,
1925            "last iter should be >= 1 after at least one accepted step"
1926        );
1927        assert!(
1928            CB_INSPECTOR_OK.load(std::sync::atomic::Ordering::SeqCst),
1929            "GetIpoptCurrentIterate did not return a usable x"
1930        );
1931        unsafe { FreeIpoptProblem(p) };
1932    }
1933
1934    unsafe extern "C" fn user_stop_cb(
1935        _alg_mod: Index,
1936        _iter_count: Index,
1937        _obj_value: Number,
1938        _inf_pr: Number,
1939        _inf_du: Number,
1940        _mu: Number,
1941        _d_norm: Number,
1942        _regularization_size: Number,
1943        _alpha_du: Number,
1944        _alpha_pr: Number,
1945        _ls_trials: Index,
1946        _user_data: *mut c_void,
1947    ) -> Bool {
1948        FALSE
1949    }
1950
1951    #[test]
1952    fn intermediate_callback_false_surfaces_user_requested_stop() {
1953        let p = create_callback_test_problem();
1954        assert!(!p.is_null());
1955        let ok = unsafe { SetIntermediateCallback(p, Some(user_stop_cb)) };
1956        assert_eq!(ok, TRUE);
1957        let mut x = [0.0_f64];
1958        let rc = unsafe {
1959            IpoptSolve(
1960                p,
1961                x.as_mut_ptr(),
1962                std::ptr::null_mut(),
1963                std::ptr::null_mut(),
1964                std::ptr::null_mut(),
1965                std::ptr::null_mut(),
1966                std::ptr::null_mut(),
1967                std::ptr::null_mut(),
1968            )
1969        };
1970        assert_eq!(rc, ApplicationReturnStatus::UserRequestedStop as Index);
1971        unsafe { FreeIpoptProblem(p) };
1972    }
1973
1974    #[test]
1975    fn parse_pkg_version_handles_missing_components() {
1976        assert_eq!(parse_pkg_version("1.2.3"), (1, 2, 3));
1977        assert_eq!(parse_pkg_version("4.5"), (4, 5, 0));
1978        assert_eq!(parse_pkg_version(""), (0, 0, 0));
1979        assert_eq!(parse_pkg_version("1.x.3"), (1, 0, 3));
1980    }
1981
1982    // ---- Solver-session C ABI (crate::solver) ----
1983
1984    use crate::solver::{
1985        IpoptCreateSolver, IpoptFreeSolver, IpoptSolverGetKktDim, IpoptSolverKktSolve,
1986        IpoptSolverSolve,
1987    };
1988
1989    #[test]
1990    fn solver_create_consumes_problem_handle() {
1991        let mut p = create_unconstrained();
1992        assert!(!p.is_null());
1993        let s = unsafe { IpoptCreateSolver(&mut p) };
1994        assert!(!s.is_null());
1995        assert!(
1996            p.is_null(),
1997            "IpoptCreateSolver should NULL out the caller's handle"
1998        );
1999        unsafe { IpoptFreeSolver(s) };
2000    }
2001
2002    #[test]
2003    fn solver_create_null_inputs_return_null() {
2004        // NULL pointer-to-handle.
2005        let s = unsafe { IpoptCreateSolver(std::ptr::null_mut()) };
2006        assert!(s.is_null());
2007        // Pointer to a NULL handle.
2008        let mut p: IpoptProblem = std::ptr::null_mut();
2009        let s = unsafe { IpoptCreateSolver(&mut p) };
2010        assert!(s.is_null());
2011    }
2012
2013    #[test]
2014    fn solver_free_null_is_safe() {
2015        unsafe { IpoptFreeSolver(std::ptr::null_mut()) };
2016    }
2017
2018    #[test]
2019    fn solver_solve_drives_quadratic_and_retains_factor() {
2020        let xl = [-1.0e20];
2021        let xu = [1.0e20];
2022        let mut p = unsafe {
2023            CreateIpoptProblem(
2024                1,
2025                xl.as_ptr(),
2026                xu.as_ptr(),
2027                0,
2028                std::ptr::null(),
2029                std::ptr::null(),
2030                0,
2031                1,
2032                0,
2033                Some(quad_eval_f),
2034                None,
2035                Some(quad_eval_grad_f),
2036                None,
2037                Some(quad_eval_h),
2038            )
2039        };
2040        assert!(!p.is_null());
2041        let s = unsafe { IpoptCreateSolver(&mut p) };
2042        assert!(!s.is_null());
2043        let mut x = [0.0_f64];
2044        let mut obj = 0.0_f64;
2045        let rc = unsafe {
2046            IpoptSolverSolve(
2047                s,
2048                x.as_mut_ptr(),
2049                std::ptr::null_mut(),
2050                &mut obj,
2051                std::ptr::null_mut(),
2052                std::ptr::null_mut(),
2053                std::ptr::null_mut(),
2054                std::ptr::null_mut(),
2055            )
2056        };
2057        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
2058        assert!((x[0] - 2.0).abs() < 1e-6);
2059        assert!(obj.abs() < 1e-10);
2060
2061        // After convergence the factor is retained — kkt_dim is positive
2062        // and a zero RHS back-solves to zero.
2063        let dim = unsafe { IpoptSolverGetKktDim(s) };
2064        assert!(dim > 0, "expected positive KKT dim, got {dim}");
2065        let rhs = vec![0.0_f64; dim as usize];
2066        let mut lhs = vec![1.0_f64; dim as usize];
2067        let ok = unsafe { IpoptSolverKktSolve(s, rhs.as_ptr(), lhs.as_mut_ptr()) };
2068        assert_eq!(ok, TRUE);
2069        for (i, v) in lhs.iter().enumerate() {
2070            assert!(v.abs() < 1e-10, "lhs[{i}] = {v} not ~0");
2071        }
2072        unsafe { IpoptFreeSolver(s) };
2073    }
2074
2075    #[test]
2076    fn solver_kkt_dim_minus_one_before_solve() {
2077        let mut p = create_unconstrained();
2078        let s = unsafe { IpoptCreateSolver(&mut p) };
2079        assert_eq!(unsafe { IpoptSolverGetKktDim(s) }, -1);
2080        unsafe { IpoptFreeSolver(s) };
2081    }
2082}