Skip to main content

pounce_cinterface/
lib.rs

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