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