Skip to main content

pounce_cinterface/
lib.rs

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