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 bounds;
35pub mod errors;
36pub mod fortran;
37pub mod qp;
38pub mod solver;
39
40pub use bounds::*;
41pub use errors::*;
42pub use qp::*;
43
44use bounds::{ComplementsLedger, ComplementsRow, ComplementsUse, DeclaredPairs};
45use pounce_algorithm::application::{
46    IpoptApplication, default_backend_factory, feral_config_from_options, ma57_config_from_options,
47};
48use pounce_algorithm::intermediate as ip_intermediate;
49use pounce_common::reg_options::OptionType;
50use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
51use pounce_nlp::mpcc::{ComplementarityPair, PairErrorKind, PairShape, classify_pair};
52use pounce_nlp::return_codes::ApplicationReturnStatus;
53use pounce_nlp::solve_statistics::SolveStatistics;
54use pounce_nlp::tnlp::{
55    BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution, SparsityRequest,
56    StartingPoint, TNLP,
57};
58use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
59use pounce_restoration::resto_inner_solver::{
60    InnerBackendFactoryFactory, make_default_restoration_factory_provider,
61};
62use pounce_restoration::second_opinion_driver::run_second_opinion_ladder;
63use std::cell::RefCell;
64use std::ffi::{CStr, c_char, c_int, c_uint, c_void};
65use std::rc::Rc;
66
67/// Mirrors C `Number` typedef in `IpStdCInterface.h`.
68pub type Number = f64;
69/// Mirrors C `Index`.
70pub type Index = c_int;
71/// Mirrors C `Bool`, which `pounce.h` — like Ipopt 3.14's
72/// `IpStdCInterface.h` — declares as the C99 `bool`. That is **one byte**,
73/// so this is `u8` and not `c_int`.
74///
75/// It was `c_int` until gh#624, i.e. four bytes on the Rust side against
76/// one on every C caller's, in both directions:
77///
78/// * a callback returning `false` sets only `AL`, and the x86-64 psABI
79///   leaves the rest of `EAX` unspecified — read as an `i32`, a failed
80///   evaluation could come back nonzero, which reads as *success*. The
81///   solver would then accept a point it was told it could not evaluate
82///   instead of cutting the step. gcc and clang emit `movzbl`, which is
83///   why this stayed latent rather than exploding;
84/// * an *array* of them would have been a hard stride bug, 1-byte
85///   elements read at 4-byte spacing. That is why
86///   [`IpoptSetNonlinearVariables`] takes a count plus an index list
87///   rather than the `Bool` mask gh#624 originally proposed.
88///
89/// `u8` rather than Rust's `bool` on purpose: the two have identical
90/// layout, but `bool` carries a validity invariant (it *must* hold 0 or
91/// 1), and a C caller reaching this boundary with anything else — an
92/// older header where `Bool` was `int`, a hand-rolled binding, a value
93/// that came through a `memcpy` — would be instant undefined behaviour.
94/// `u8` accepts whatever arrives and tests it the way C does, which is
95/// the same reason the entry points validate rather than trust.
96pub type Bool = u8;
97
98const TRUE: Bool = 1;
99const FALSE: Bool = 0;
100
101// The whole point of the alias. `pounce.h` says `typedef bool Bool`, so a
102// build where this stops being one byte is one where every C caller
103// disagrees with the implementation about every boolean.
104const _: () = assert!(
105    core::mem::size_of::<Bool>() == 1,
106    "Bool must be one byte to match `typedef bool Bool` in pounce.h"
107);
108
109/// Run an FFI entry-point body, converting any Rust panic into `fallback`
110/// rather than letting it unwind across the `extern "C"` boundary — which is
111/// undefined behavior and, in practice, a process abort that takes the
112/// embedding application down with it. Upstream Ipopt's C interface likewise
113/// wraps the solve in `try { … } catch(…)` and reports `Internal_Error`
114/// instead of propagating a C++ exception across the ABI.
115///
116/// Note: this guards panics that originate in *pounce's own* Rust code (the
117/// solver core, the callback bridge, numerical kernels). A panic inside a
118/// user-supplied `extern "C"` callback aborts at that callback's own ABI
119/// boundary, before unwinding can reach here — that is the caller's
120/// responsibility, exactly as in the C/C++ original.
121pub(crate) fn ffi_guard<R>(fallback: R, body: impl FnOnce() -> R) -> R {
122    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
123        Ok(r) => r,
124        Err(_) => fallback,
125    }
126}
127
128/// An *extensible* caller-allocated output struct (gh#1147): its first
129/// member is `ipindex struct_size`, which the caller sets to `sizeof(T)`
130/// before the call. `PounceComplementarityStats`, `PounceComplementaritySolve`,
131/// `PounceQpResiduals` and `PounceQpCertificate` follow it;
132/// `PounceLinearSolverStats` does not (it shipped before the convention).
133///
134/// # The growth rule
135///
136/// A later release may only **append** fields at the end; nothing is
137/// removed, reordered or widened. Every getter then writes
138/// `min(caller's struct_size, its own size_of::<T>())` bytes — which is what
139/// `write_extensible` does for this release, whose size is the minimum
140/// whenever the call is accepted at all.
141///
142/// # Safety
143///
144/// Implementors must be `#[repr(C)]` with `struct_size: Index` as the first
145/// field, so it sits at offset 0 (asserted by
146/// `tests/c_examples_build_and_run.rs`).
147pub(crate) unsafe trait ExtensibleOut: Copy {}
148
149/// This release's `sizeof(T)`, as the `struct_size` value a caller passes.
150pub(crate) const fn struct_size_of<T: ExtensibleOut>() -> Index {
151    std::mem::size_of::<T>() as Index
152}
153
154/// Write `value` into the caller's `out` under the `struct_size` contract.
155///
156/// Returns `false`, writing nothing at all, when the caller's
157/// `struct_size` is smaller than this release's `size_of::<T>()` (a
158/// forgotten or garbage initializer: `0`, negative, or a size from a
159/// release that predates a field of `T` — there is none yet). Otherwise
160/// copies **exactly this release's bytes** of `value` after `struct_size`
161/// and returns `true`; `struct_size` itself is read, never written, and no
162/// byte past `size_of::<T>()` is touched, however large `struct_size` is
163/// (a caller built against a newer header keeps its newer fields as they
164/// were).
165///
166/// The copy is a bounded byte copy, not `*out = value`, on purpose: a
167/// whole-struct assignment is a contract about *this* release's size that
168/// would silently stay one when a field is appended. When appending a field
169/// to `T` in a later release, keep this function, and change the refusal
170/// below to the size of the oldest layout still accepted (the size *before*
171/// the first appended field), writing `min(struct_size, size_of::<T>())`
172/// bytes; never lower the floor to accept `struct_size` values a getter
173/// cannot tell from an uninitialized one.
174///
175/// # Safety
176///
177/// `out` must be non-NULL, aligned for `T`, readable for its first
178/// `size_of::<Index>()` bytes and, when the call is accepted, writable for
179/// `size_of::<T>()` bytes (the caller promised `struct_size` of them).
180pub(crate) unsafe fn write_extensible<T: ExtensibleOut>(out: *mut T, value: &T) -> bool {
181    const HEAD: usize = std::mem::size_of::<Index>();
182    let size = std::mem::size_of::<T>();
183    // SAFETY: `struct_size` is at offset 0 (the trait's contract) and the
184    // caller guarantees the first `HEAD` bytes are readable.
185    let claimed = unsafe { std::ptr::read(out.cast::<Index>()) };
186    if usize::try_from(claimed).map_or(true, |c| c < size) {
187        return false;
188    }
189    // SAFETY: `claimed >= size`, so the caller promised `size` writable
190    // bytes; `value` is a distinct Rust value, so the ranges cannot overlap.
191    // Only bytes `HEAD..size` are written: `struct_size` stays the caller's.
192    unsafe {
193        std::ptr::copy_nonoverlapping(
194            (value as *const T).cast::<u8>().add(HEAD),
195            out.cast::<u8>().add(HEAD),
196            size - HEAD,
197        );
198    }
199    true
200}
201
202/// C-ABI encoding of [`pounce_qp::BoundStatus`] (§7.2 of the
203/// active-set-SQP design note). Stable values:
204/// `0 = Inactive`, `1 = AtLower`, `2 = AtUpper`, `3 = Fixed`.
205pub type IpoptBoundStatus = c_int;
206/// C-ABI encoding of [`pounce_qp::ConsStatus`] (§7.2). Stable values:
207/// `0 = Inactive`, `1 = AtLower`, `2 = AtUpper`, `3 = Equality`.
208pub type IpoptConsStatus = c_int;
209
210const POUNCE_WS_INACTIVE: c_int = 0;
211const POUNCE_WS_AT_LOWER: c_int = 1;
212const POUNCE_WS_AT_UPPER: c_int = 2;
213const POUNCE_WS_FIXED_OR_EQ: c_int = 3;
214
215/// Internal owned state behind the opaque `IpoptProblem` handle.
216/// `#[repr(C)]` is unnecessary because C only sees the pointer.
217pub struct IpoptProblemInfo {
218    pub(crate) app: IpoptApplication,
219    pub(crate) n: Index,
220    pub(crate) m: Index,
221    pub(crate) nele_jac: Index,
222    pub(crate) nele_hess: Index,
223    pub(crate) index_style: Index,
224    pub(crate) x_l: Vec<Number>,
225    pub(crate) x_u: Vec<Number>,
226    pub(crate) g_l: Vec<Number>,
227    pub(crate) g_u: Vec<Number>,
228    pub(crate) eval_f: Option<Eval_F_CB>,
229    pub(crate) eval_g: Option<Eval_G_CB>,
230    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
231    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
232    pub(crate) eval_h: Option<Eval_H_CB>,
233    pub(crate) intermediate_cb: Option<Intermediate_CB>,
234    /// User-provided scaling installed by [`SetIpoptProblemScaling`].
235    /// `obj_scaling` defaults to `1.0`. `x_scaling`/`g_scaling` are
236    /// `None` when the user passed NULL.
237    pub(crate) user_scaling: Option<UserScaling>,
238    /// Final iterate and stats from the most recent [`IpoptSolve`].
239    /// Used by `GetIpopt{IterCount,SolveTime,...}` accessors. Reset
240    /// (cleared) by the next `IpoptSolve` call.
241    pub(crate) last_solve: Option<LastSolve>,
242    /// Working set staged by [`IpoptSetWarmStartWorkingSet`], pending
243    /// until the next [`IpoptSolve`].
244    ///
245    /// Only the working set is stored here — deliberately *not* a full
246    /// `SqpIterates`. The primal/dual iterate is not knowable at set
247    /// time; it arrives with the `x` (and, under
248    /// `warm_start_init_point=yes`, `mult_g`/`mult_x_L`/`mult_x_U`)
249    /// buffers passed to `IpoptSolve`. Building the `SqpIterates`
250    /// eagerly here forced the primal to a placeholder — zeros — which
251    /// then *became* the starting iterate, because
252    /// `SqpAlgorithm::optimize_with_warm_start` uses a supplied warm
253    /// iterate instead of querying the NLP for a starting point. The
254    /// merge therefore has to happen inside `IpoptSolve` (gh#484).
255    pub(crate) pending_working_set: Option<pounce_qp::WorkingSet>,
256    /// Nonlinear-variable subset staged by
257    /// [`IpoptSetNonlinearVariables`] (gh#624). Stored in the problem's
258    /// own index style, exactly as the caller passed it; the bridge
259    /// TNLP serves it back through
260    /// `get_number_of_nonlinear_variables` /
261    /// `get_list_of_nonlinear_variables`, which is where the algorithm
262    /// reads it. `None` — the default — means "every variable is
263    /// nonlinear".
264    pub(crate) nonlinear_vars: Option<Vec<Index>>,
265    /// Complementarity pairs declared by [`AddPounceComplementarityPair`] /
266    /// [`SetPounceComplementarityPairs`] (gh#1147), **0-based** whatever
267    /// the problem's index style, in declaration order. Every stored set
268    /// passed [`pounce_nlp::mpcc::validate_pairs`] against this handle's
269    /// bounds when it was declared. Empty — the default — means no pairs.
270    pub(crate) complementarity: DeclaredPairs,
271    /// Every `(row, var)`, 0-based, that [`AddPounceComplementsConstraint`]
272    /// accepted on this handle, whether or not it declared a pair. The
273    /// helper refuses a row or variable already in here (or in
274    /// `complementarity`), so one `F` or one `x` cannot be in two MOI
275    /// `Complements` constraints. Never cleared: the bound edits it records
276    /// are not undone either.
277    pub(crate) complements_used: ComplementsLedger,
278}
279
280/// User-provided NLP scaling stored on the problem until
281/// [`IpoptSolve`] copies it into the [`CCallbackTnlp`] bridge.
282#[derive(Clone)]
283pub(crate) struct UserScaling {
284    obj_scaling: Number,
285    x_scaling: Option<Vec<Number>>,
286    g_scaling: Option<Vec<Number>>,
287}
288
289/// Stats and final-iterate snapshot retained between
290/// [`IpoptSolve`] and the post-solve accessors. Everything needed to
291/// reconstruct a `pounce.solve-report/v1` JSON file lives here so
292/// [`IpoptWriteSolveReport`] doesn't have to ask the caller to thread
293/// `x`/`lambda`/`obj` back in.
294#[derive(Clone)]
295pub(crate) struct LastSolve {
296    pub(crate) stats: SolveStatistics,
297    pub(crate) status: ApplicationReturnStatus,
298    pub(crate) linear_solver: Option<pounce_linsol::summary::LinearSolverSummary>,
299    pub(crate) final_x: Vec<Number>,
300    pub(crate) final_lambda: Vec<Number>,
301    pub(crate) final_obj: Number,
302    /// The complementarity driver's record when the solve declared pairs
303    /// (gh#1147); `None` on every other solve, including every session solve.
304    pub(crate) mpcc: Option<pounce_restoration::mpcc_driver::MpccOutcome>,
305    /// The second-opinion ladder's outcome when the solve took the path
306    /// without pairs (gh#850, gh#1147); `None` on a driver solve and on
307    /// every session solve, neither of which runs the ladder. Kept whether
308    /// or not a rung ran: [`IpoptWriteSolveReport`] writes the block under
309    /// the same `ran()` test as the CLI and Python.
310    pub(crate) second_opinion:
311        Option<pounce_restoration::second_opinion_driver::SecondOpinionOutcome>,
312}
313
314impl Default for LastSolve {
315    fn default() -> Self {
316        Self {
317            stats: SolveStatistics::default(),
318            status: ApplicationReturnStatus::InternalError,
319            linear_solver: None,
320            final_x: Vec::new(),
321            final_lambda: Vec::new(),
322            final_obj: 0.0,
323            mpcc: None,
324            second_opinion: None,
325        }
326    }
327}
328
329pub type IpoptProblem = *mut IpoptProblemInfo;
330
331// User-callback function pointer types — match
332// `IpStdCInterface.h:Eval_F_CB` etc. byte for byte.
333
334pub type Eval_F_CB = unsafe extern "C" fn(
335    n: Index,
336    x: *const Number,
337    new_x: Bool,
338    obj_value: *mut Number,
339    user_data: *mut c_void,
340) -> Bool;
341
342pub type Eval_Grad_F_CB = unsafe extern "C" fn(
343    n: Index,
344    x: *const Number,
345    new_x: Bool,
346    grad_f: *mut Number,
347    user_data: *mut c_void,
348) -> Bool;
349
350pub type Eval_G_CB = unsafe extern "C" fn(
351    n: Index,
352    x: *const Number,
353    new_x: Bool,
354    m: Index,
355    g: *mut Number,
356    user_data: *mut c_void,
357) -> Bool;
358
359pub type Eval_Jac_G_CB = unsafe extern "C" fn(
360    n: Index,
361    x: *const Number,
362    new_x: Bool,
363    m: Index,
364    nele_jac: Index,
365    iRow: *mut Index,
366    jCol: *mut Index,
367    values: *mut Number,
368    user_data: *mut c_void,
369) -> Bool;
370
371pub type Eval_H_CB = unsafe extern "C" fn(
372    n: Index,
373    x: *const Number,
374    new_x: Bool,
375    obj_factor: Number,
376    m: Index,
377    lambda: *const Number,
378    new_lambda: Bool,
379    nele_hess: Index,
380    iRow: *mut Index,
381    jCol: *mut Index,
382    values: *mut Number,
383    user_data: *mut c_void,
384) -> Bool;
385
386pub type Intermediate_CB = unsafe extern "C" fn(
387    alg_mod: Index,
388    iter_count: Index,
389    obj_value: Number,
390    inf_pr: Number,
391    inf_du: Number,
392    mu: Number,
393    d_norm: Number,
394    regularization_size: Number,
395    alpha_du: Number,
396    alpha_pr: Number,
397    ls_trials: Index,
398    user_data: *mut c_void,
399) -> Bool;
400
401/// Port of `IpStdCInterface.cpp:CreateIpoptProblem`. Returns NULL on
402/// invalid arguments (negative n/m, missing required callbacks, NULL
403/// bound pointers when the corresponding dimension is positive).
404///
405/// # Safety
406///
407/// `x_L`, `x_U` must be valid pointers to `n` `Number`s when `n > 0`.
408/// `g_L`, `g_U` must be valid pointers to `m` `Number`s when `m > 0`.
409/// The callback function pointers must be valid for the lifetime of
410/// the returned [`IpoptProblem`].
411#[unsafe(no_mangle)]
412pub unsafe extern "C" fn CreateIpoptProblem(
413    n: Index,
414    x_L: *const Number,
415    x_U: *const Number,
416    m: Index,
417    g_L: *const Number,
418    g_U: *const Number,
419    nele_jac: Index,
420    nele_hess: Index,
421    index_style: Index,
422    eval_f: Option<Eval_F_CB>,
423    eval_g: Option<Eval_G_CB>,
424    eval_grad_f: Option<Eval_Grad_F_CB>,
425    eval_jac_g: Option<Eval_Jac_G_CB>,
426    eval_h: Option<Eval_H_CB>,
427) -> IpoptProblem {
428    unsafe {
429        // Install the tracing subscriber on first use so C consumers
430        // (cyipopt, AMPL, …) get logging and the iteration collector that
431        // backs `IpoptEnableIterHistory` (pounce#71). Idempotent.
432        pounce_observability::init_subscriber();
433
434        if n < 0 || m < 0 || nele_jac < 0 || nele_hess < 0 {
435            return std::ptr::null_mut();
436        }
437        if !(0..=1).contains(&index_style) {
438            return std::ptr::null_mut();
439        }
440        if eval_f.is_none() || eval_grad_f.is_none() {
441            return std::ptr::null_mut();
442        }
443        if m > 0 && (eval_g.is_none() || eval_jac_g.is_none()) {
444            return std::ptr::null_mut();
445        }
446        if n > 0 && (x_L.is_null() || x_U.is_null()) {
447            return std::ptr::null_mut();
448        }
449        if m > 0 && (g_L.is_null() || g_U.is_null()) {
450            return std::ptr::null_mut();
451        }
452
453        let x_l = if n > 0 {
454            std::slice::from_raw_parts(x_L, n as usize).to_vec()
455        } else {
456            Vec::new()
457        };
458        let x_u = if n > 0 {
459            std::slice::from_raw_parts(x_U, n as usize).to_vec()
460        } else {
461            Vec::new()
462        };
463        let g_l_vec = if m > 0 {
464            std::slice::from_raw_parts(g_L, m as usize).to_vec()
465        } else {
466            Vec::new()
467        };
468        let g_u_vec = if m > 0 {
469            std::slice::from_raw_parts(g_U, m as usize).to_vec()
470        } else {
471            Vec::new()
472        };
473
474        let info = Box::new(IpoptProblemInfo {
475            app: IpoptApplication::new(),
476            n,
477            m,
478            nele_jac,
479            nele_hess,
480            index_style,
481            x_l,
482            x_u,
483            g_l: g_l_vec,
484            g_u: g_u_vec,
485            eval_f,
486            eval_g,
487            eval_grad_f,
488            eval_jac_g,
489            eval_h,
490            intermediate_cb: None,
491            user_scaling: None,
492            nonlinear_vars: None,
493            complementarity: DeclaredPairs::default(),
494            complements_used: ComplementsLedger::default(),
495            last_solve: None,
496            pending_working_set: None,
497        });
498        Box::into_raw(info)
499    }
500}
501
502/// Port of `IpStdCInterface.cpp:FreeIpoptProblem`.
503///
504/// # Safety
505///
506/// `ipopt_problem` must be a pointer previously returned by
507/// [`CreateIpoptProblem`] and not yet freed, or NULL.
508#[unsafe(no_mangle)]
509pub unsafe extern "C" fn FreeIpoptProblem(ipopt_problem: IpoptProblem) {
510    unsafe {
511        if ipopt_problem.is_null() {
512            return;
513        }
514        drop(Box::from_raw(ipopt_problem));
515    }
516}
517
518unsafe fn keyword_str<'a>(keyword: *const c_char) -> Option<&'a str> {
519    unsafe {
520        if keyword.is_null() {
521            return None;
522        }
523        CStr::from_ptr(keyword).to_str().ok()
524    }
525}
526
527/// Port of `IpStdCInterface.cpp:AddIpoptStrOption`.
528///
529/// # Safety
530///
531/// `ipopt_problem` must be a valid `IpoptProblem`. `keyword` and `val`
532/// must be valid NUL-terminated strings.
533#[unsafe(no_mangle)]
534pub unsafe extern "C" fn AddIpoptStrOption(
535    ipopt_problem: IpoptProblem,
536    keyword: *const c_char,
537    val: *const c_char,
538) -> Bool {
539    unsafe {
540        if ipopt_problem.is_null() {
541            return FALSE;
542        }
543        let info = &mut *ipopt_problem;
544        let Some(k) = keyword_str(keyword) else {
545            return FALSE;
546        };
547        if val.is_null() {
548            return FALSE;
549        }
550        let Ok(v) = CStr::from_ptr(val).to_str() else {
551            return FALSE;
552        };
553        match info.app.options_mut().set_string_value(k, v, true, false) {
554            Ok(_) => TRUE,
555            Err(_) => FALSE,
556        }
557    }
558}
559
560/// Port of `AddIpoptNumOption`.
561///
562/// # Safety
563///
564/// `keyword` must be a valid NUL-terminated string and
565/// `ipopt_problem` must be a valid `IpoptProblem`.
566#[unsafe(no_mangle)]
567pub unsafe extern "C" fn AddIpoptNumOption(
568    ipopt_problem: IpoptProblem,
569    keyword: *const c_char,
570    val: Number,
571) -> Bool {
572    unsafe {
573        if ipopt_problem.is_null() {
574            return FALSE;
575        }
576        let info = &mut *ipopt_problem;
577        let Some(k) = keyword_str(keyword) else {
578            return FALSE;
579        };
580        match info
581            .app
582            .options_mut()
583            .set_numeric_value(k, val, true, false)
584        {
585            Ok(_) => TRUE,
586            Err(_) => FALSE,
587        }
588    }
589}
590
591/// Port of `AddIpoptIntOption`.
592///
593/// # Safety
594///
595/// `keyword` must be a valid NUL-terminated string and
596/// `ipopt_problem` must be a valid `IpoptProblem`.
597#[unsafe(no_mangle)]
598pub unsafe extern "C" fn AddIpoptIntOption(
599    ipopt_problem: IpoptProblem,
600    keyword: *const c_char,
601    val: Index,
602) -> Bool {
603    unsafe {
604        if ipopt_problem.is_null() {
605            return FALSE;
606        }
607        let info = &mut *ipopt_problem;
608        let Some(k) = keyword_str(keyword) else {
609            return FALSE;
610        };
611        match info.app.options_mut().set_integer_value(
612            k,
613            val as pounce_common::types::Index,
614            true,
615            false,
616        ) {
617            Ok(_) => TRUE,
618            Err(_) => FALSE,
619        }
620    }
621}
622
623/// Port of `IpStdCInterface.cpp:OpenIpoptOutputFile`. Opens `file_name`
624/// at `print_level` and attaches a journalist `FileJournal` so all
625/// solver output is mirrored to disk. Equivalent to setting
626/// `output_file` + `file_print_level` options and triggering
627/// `IpoptApplication::Initialize`.
628///
629/// Returns `TRUE` (1) on success, `FALSE` (0) if the file could not
630/// be opened or the option store rejected the value.
631///
632/// # Safety
633///
634/// `ipopt_problem` must be a valid `IpoptProblem`. `file_name` must
635/// be a valid NUL-terminated string.
636#[unsafe(no_mangle)]
637pub unsafe extern "C" fn OpenIpoptOutputFile(
638    ipopt_problem: IpoptProblem,
639    file_name: *const c_char,
640    print_level: c_int,
641) -> Bool {
642    unsafe {
643        if ipopt_problem.is_null() || file_name.is_null() {
644            return FALSE;
645        }
646        let info = &mut *ipopt_problem;
647        let Ok(fname) = CStr::from_ptr(file_name).to_str() else {
648            return FALSE;
649        };
650        if info.app.open_output_file(fname, print_level) {
651            TRUE
652        } else {
653            FALSE
654        }
655    }
656}
657
658/// Port of `IpStdCInterface.cpp:SetIpoptProblemScaling`. Stores
659/// user-provided NLP scaling on the problem; the scaling is forwarded
660/// to the solver via [`TNLP::get_scaling_parameters`] when the option
661/// `nlp_scaling_method=user-scaling` is set. Passing NULL for
662/// `x_scaling` / `g_scaling` disables scaling on that axis.
663///
664/// Always returns `TRUE` (the upstream contract). A non-trivial
665/// `x_scaling` is applied as a change of variables, so the solution and
666/// bound multipliers `IpoptSolve` writes back are in the caller's own
667/// units (gh#486). A factor that is not finite and positive is refused
668/// at solve time, where [`IpoptSolve`] returns `Invalid_Option` and the
669/// journalist explains why: store-time validation is not an option,
670/// because the C signature has no way to report it.
671///
672/// # Safety
673///
674/// `ipopt_problem` must be a valid `IpoptProblem`. When non-NULL,
675/// `x_scaling` must point to `n` doubles and `g_scaling` to `m`
676/// doubles; both arrays are copied internally.
677#[unsafe(no_mangle)]
678pub unsafe extern "C" fn SetIpoptProblemScaling(
679    ipopt_problem: IpoptProblem,
680    obj_scaling: Number,
681    x_scaling: *const Number,
682    g_scaling: *const Number,
683) -> Bool {
684    unsafe {
685        if ipopt_problem.is_null() {
686            return FALSE;
687        }
688        let info = &mut *ipopt_problem;
689        let n = info.n as usize;
690        let m = info.m as usize;
691        let x_vec = if !x_scaling.is_null() && n > 0 {
692            Some(std::slice::from_raw_parts(x_scaling, n).to_vec())
693        } else {
694            None
695        };
696        let g_vec = if !g_scaling.is_null() && m > 0 {
697            Some(std::slice::from_raw_parts(g_scaling, m).to_vec())
698        } else {
699            None
700        };
701        info.user_scaling = Some(UserScaling {
702            obj_scaling,
703            x_scaling: x_vec,
704            g_scaling: g_vec,
705        });
706        TRUE
707    }
708}
709
710/// Port of `IpStdCInterface.cpp:IpoptSolve`. Returns the
711/// `ApplicationReturnStatus` integer.
712///
713/// Builds a [`CCallbackTnlp`] from the user-supplied callback table
714/// and bounds, runs it through [`IpoptApplication::optimize_tnlp`],
715/// and writes back the final iterate.
716///
717/// With complementarity pairs declared (gh#1147) the bridge goes to
718/// [`pounce_restoration::mpcc_driver::solve_mpcc`] instead of one
719/// `optimize_tnlp` and the second-opinion ladder, as in the CLI and Python;
720/// the driver forwards its reported point to the bridge once, so the
721/// write-back is the same. A staged SQP working set plus pairs is refused
722/// with `Invalid_Problem_Definition` before any callback.
723///
724/// # Safety
725///
726/// All pointer arguments are read/written per the
727/// `IpStdCInterface.h` contract: `x` is in/out (size `n`); `g` is
728/// out-only (size `m`); `mult_g`, `mult_x_L`, `mult_x_U` (sizes
729/// `m, n, n`) are out, and are also *read* as the starting multipliers
730/// when `warm_start_init_point=yes` (upstream's `start_lam`, `start_z_L`,
731/// `start_z_U`). Any of them may be NULL; a NULL multiplier buffer under
732/// a warm start means "not supplied" and that block starts unseeded.
733#[allow(clippy::too_many_arguments)]
734#[unsafe(no_mangle)]
735pub unsafe extern "C" fn IpoptSolve(
736    ipopt_problem: IpoptProblem,
737    x: *mut Number,
738    g: *mut Number,
739    obj_val: *mut Number,
740    mult_g: *mut Number,
741    mult_x_L: *mut Number,
742    mult_x_U: *mut Number,
743    user_data: *mut c_void,
744) -> Index {
745    unsafe {
746        if ipopt_problem.is_null() {
747            return ApplicationReturnStatus::InternalError as Index;
748        }
749        // Invalidate the retained stats up front, before the solve is attempted.
750        // The `last_solve` snapshot is only repopulated at the *end* of a
751        // completed solve, so if the guarded body below bails early or a panic is
752        // caught (returning `Internal_Error`), the post-solve accessors
753        // (`GetIpoptIterCount`, `IpoptWriteSolveReport`, …) must not silently
754        // report the *previous* solve's stats. Clearing here makes the
755        // failure-consistent state "no data" rather than stale data (F5).
756        (*ipopt_problem).last_solve = None;
757        // Guard the whole solve: `optimize_tnlp` runs the entire pounce core and
758        // callback bridge, any of which could panic on an unexpected internal
759        // state. Without this, such a panic would unwind across `extern "C"` and
760        // abort the embedding process; instead we report `Internal_Error`,
761        // matching upstream Ipopt's exception handling. (See `ffi_guard`.)
762        ffi_guard(ApplicationReturnStatus::InternalError as Index, || {
763            let info = &mut *ipopt_problem;
764            // Every solve states, for its own duration, whether the live
765            // iterate on this thread is a lowered model's (gh#1147 review):
766            // a pair-free solve nested inside a lowered solve's callback
767            // clears the flag, so its own inspectors answer, and the outer
768            // value comes back when it returns.
769            let _lowered = LoweredSolveGuard::set(!info.complementarity.is_empty());
770            if info.n < 0 || info.m < 0 {
771                return ApplicationReturnStatus::InvalidProblemDefinition as Index;
772            }
773            if info.n > 0 && x.is_null() {
774                return ApplicationReturnStatus::InvalidProblemDefinition as Index;
775            }
776            // A staged SQP working set is over the caller's rows and
777            // variables; the complementarity driver solves a lowered model
778            // with more rows (and possibly lifted variables), so there is no
779            // set it could seed. Refused before any callback runs, and
780            // neither the working set nor the pairs are consumed (gh#1147).
781            if !info.complementarity.is_empty() && info.pending_working_set.is_some() {
782                if pounce_algorithm::second_opinion::narration_is_wanted(info.app.options()) {
783                    eprintln!(
784                        "pounce: a warm-start working set cannot seed a solve with \
785                         complementarity pairs ({} declared); clear one of them \
786                         (IpoptClearWarmStartWorkingSet or ClearPounceComplementarityPairs).",
787                        info.complementarity.len()
788                    );
789                }
790                return ApplicationReturnStatus::InvalidProblemDefinition as Index;
791            }
792
793            let n_us = info.n as usize;
794            let m_us = info.m as usize;
795            let initial_x = if n_us > 0 {
796                std::slice::from_raw_parts(x, n_us).to_vec()
797            } else {
798                Vec::new()
799            };
800
801            // Merge any working set staged by
802            // `IpoptSetWarmStartWorkingSet` with the iterate the caller
803            // actually supplied. This is the point at which the primal
804            // starting point is known, so it is the only correct place
805            // to build the `SqpIterates` (gh#484).
806            //
807            // Duals follow upstream Ipopt's `IpoptSolve` contract:
808            // `mult_g` / `mult_x_L` / `mult_x_U` are inputs only when
809            // `warm_start_init_point=yes`, and out-only otherwise. A
810            // caller who has not opted in may pass uninitialized
811            // buffers, so reading them unconditionally would seed the
812            // SQP with garbage multipliers.
813            if let Some(working) = info.pending_working_set.take() {
814                let seed_duals = matches!(
815                    info.app
816                        .options()
817                        .get_bool_value("warm_start_init_point", ""),
818                    Ok((true, true))
819                );
820                let read_in = |p: *const Number, len: usize| -> Vec<Number> {
821                    if seed_duals && !p.is_null() && len > 0 {
822                        std::slice::from_raw_parts(p, len).to_vec()
823                    } else {
824                        vec![0.0; len]
825                    }
826                };
827                let lambda_g = read_in(mult_g as *const Number, m_us);
828                let z_l = read_in(mult_x_L as *const Number, n_us);
829                let z_u = read_in(mult_x_U as *const Number, n_us);
830                // SQP packs the bound multipliers signed, as
831                // `lambda_x = z_l − z_u` (see `sqp::warm_start`). All three
832                // stay in the caller's space (`g` order, unscaled, full `n`);
833                // `set_sqp_warm_start` maps them into the algorithm's.
834                let lambda_x = z_l.iter().zip(&z_u).map(|(l, u)| l - u).collect();
835                info.app
836                    .set_sqp_warm_start(pounce_algorithm::sqp::SqpIterates {
837                        x: initial_x.clone(),
838                        lambda_g,
839                        lambda_x,
840                        working: Some(working),
841                    });
842            }
843
844            let (lower_inf, upper_inf, zero_fixed_grad_lag) =
845                bridge_violation_options(info.app.options());
846            let bridge = Rc::new(RefCell::new(CCallbackTnlp {
847                n: info.n,
848                m: info.m,
849                nele_jac: info.nele_jac,
850                nele_hess: info.nele_hess,
851                index_style: info.index_style,
852                x_l: info.x_l.clone(),
853                x_u: info.x_u.clone(),
854                g_l: info.g_l.clone(),
855                g_u: info.g_u.clone(),
856                initial_x,
857                start_lambda: mult_g as *const Number,
858                start_z_l: mult_x_L as *const Number,
859                start_z_u: mult_x_U as *const Number,
860                eval_f: info.eval_f,
861                eval_grad_f: info.eval_grad_f,
862                eval_g: info.eval_g,
863                eval_jac_g: info.eval_jac_g,
864                eval_h: info.eval_h,
865                user_data,
866                intermediate_cb: info.intermediate_cb,
867                user_scaling: info.user_scaling.clone(),
868                nonlinear_vars: info.nonlinear_vars.clone(),
869                lower_inf,
870                upper_inf,
871                zero_fixed_grad_lag,
872                final_status: None,
873                final_x: vec![0.0; n_us],
874                final_z_l: vec![0.0; n_us],
875                final_z_u: vec![0.0; n_us],
876                final_g: vec![0.0; m_us],
877                final_lambda: vec![0.0; m_us],
878                final_obj: 0.0,
879            }));
880
881            // Wire the restoration phase fresh for this solve. Without it, any
882            // line-search failure surfaces as `RestorationFailure` instead of
883            // falling back into the ℓ1-feasibility sub-IPM — exactly what the
884            // CLI driver does. Re-wire per `IpoptSolve` to stay correct across
885            // repeated solves on the same `IpoptProblem`. The feral config is
886            // snapshot from the now-fully-populated options so `feral_*`
887            // overrides flow into the restoration sub-IPM too. Use the multi-pass
888            // provider so the ℓ₁ wrapper / auto-fallback don't panic on the
889            // second inner solve (pounce#10 Phase 3 / pounce#24).
890            let feral_cfg = feral_config_from_options(info.app.options());
891            // The `ma57_*` options under the `"resto."` prefix — dead until
892            // gh#825, because nothing threaded any MA57 config into a factory.
893            let ma57_cfg = ma57_config_from_options(info.app.options(), "resto.");
894            let bff_mint = move || -> InnerBackendFactoryFactory {
895                let feral_cfg = feral_cfg.clone();
896                let ma57_cfg = ma57_cfg.clone();
897                Box::new(move || default_backend_factory(feral_cfg.clone(), ma57_cfg.clone()))
898            };
899            let resto_provider = make_default_restoration_factory_provider(
900                RestoAlgorithmBuilder::new(),
901                info.app.algorithm_builder_from_options(),
902                bff_mint,
903            );
904            info.app.set_restoration_factory_provider(resto_provider);
905
906            let bridge_for_solve: Rc<RefCell<dyn TNLP>> = bridge.clone();
907            // The run-ending `EXIT:` / `POUNCE <version>:` verdict belongs to the
908            // whole run, not to each attempt. Deferred from here through the
909            // second-opinion ladder below, which releases it and prints it once
910            // with the status that actually ships. Without this, every retry
911            // driver's attempt printed its own verdict and a run that recovered
912            // reported a mid-run one that read as the final answer.
913            info.app.defer_end_verdict();
914            // Declared complementarity (gh#1147): the driver chooses the
915            // formulation, runs the lowered solves and certifies the answer,
916            // as in the CLI and Python. It replaces the single solve and the
917            // second-opinion ladder, which both assume one solve of one model,
918            // and forwards the reported point to the bridge's
919            // `finalize_solution` exactly once, so the copy-back below is the
920            // same for both paths.
921            let (status, statistics, mpcc, second_opinion) = if !info.complementarity.is_empty() {
922                let narrate =
923                    pounce_algorithm::second_opinion::narration_is_wanted(info.app.options());
924                let result = pounce_restoration::mpcc_driver::solve_mpcc(
925                    &mut info.app,
926                    bridge_for_solve,
927                    info.complementarity.to_vec(),
928                    &mut |line| {
929                        if narrate {
930                            eprintln!("{line}");
931                        }
932                    },
933                );
934                let out = match result {
935                    Ok(out) => out,
936                    // Unreachable from C: the only `Err` is `MpccTnlp::with_lift`
937                    // refusing the pairs, and the stored set passed the same
938                    // validator against the same bounds at declaration. Kept
939                    // as a refusal rather than an assumption.
940                    Err(e) => {
941                        if narrate {
942                            eprintln!(
943                                "pounce: cannot solve this model's complementarity constraints: {e}"
944                            );
945                        }
946                        let status = ApplicationReturnStatus::InvalidProblemDefinition;
947                        if info.app.release_end_verdict() {
948                            info.app.print_end_verdict(status);
949                        }
950                        return status as Index;
951                    }
952                };
953                if narrate {
954                    eprintln!(
955                        "{}",
956                        pounce_algorithm::mpcc::describe_outcome(
957                            out.reported,
958                            out.solves.len(),
959                            out.total_iterations,
960                            out.max_abs_product,
961                            out.certificate.as_ref(),
962                        )
963                    );
964                }
965                if info.app.release_end_verdict() {
966                    info.app.print_end_verdict(out.status);
967                }
968                (out.status, out.statistics.clone(), Some(out), None)
969            } else {
970                // The path without pairs, unchanged: one solve, then the ladder.
971                let status = info.app.optimize_tnlp(bridge_for_solve);
972                let stats = info.app.statistics();
973                // Second-opinion ladder, on by default as it is in the CLI and the
974                // Python frontend: an `Infeasible_Problem_Detected` or
975                // `Invalid_Number_Detected` is re-solved along up to three
976                // deliberately different trajectories and a re-solve is promoted
977                // only if it converges. A converged solve pays nothing — the
978                // ladder reads the status and returns. The three `*_retry`
979                // options turn individual rungs off.
980                //
981                // Narration goes to stderr, where the solver's own banners already
982                // go -- but gated on `print_level >= 1`. This crate is the Ipopt
983                // drop-in, so `print_level=0 sb=yes` is a caller asking for
984                // silence, and eight unexpected `pounce:` lines on a failing
985                // solve is exactly what that asks not to happen. The ladder still runs;
986                // only the console is quiet.
987                let narrate =
988                    pounce_algorithm::second_opinion::narration_is_wanted(info.app.options());
989                let ladder = run_second_opinion_ladder(
990                    &mut info.app,
991                    bridge.clone() as Rc<RefCell<dyn TNLP>>,
992                    status,
993                    stats,
994                    &mut |line| {
995                        if narrate {
996                            eprintln!("{line}");
997                        }
998                    },
999                );
1000                (ladder.status, ladder.statistics.clone(), None, Some(ladder))
1001            };
1002            let bridge_ref = bridge.borrow();
1003            info.last_solve = Some(LastSolve {
1004                stats: statistics,
1005                status,
1006                linear_solver: info.app.linear_solver_summary(),
1007                final_x: bridge_ref.final_x.clone(),
1008                final_lambda: bridge_ref.final_lambda.clone(),
1009                final_obj: bridge_ref.final_obj,
1010                mpcc,
1011                second_opinion,
1012            });
1013            if !x.is_null() && n_us > 0 {
1014                std::ptr::copy_nonoverlapping(bridge_ref.final_x.as_ptr(), x, n_us);
1015            }
1016            if !g.is_null() && m_us > 0 {
1017                std::ptr::copy_nonoverlapping(bridge_ref.final_g.as_ptr(), g, m_us);
1018            }
1019            if !obj_val.is_null() {
1020                *obj_val = bridge_ref.final_obj;
1021            }
1022            if !mult_g.is_null() && m_us > 0 {
1023                std::ptr::copy_nonoverlapping(bridge_ref.final_lambda.as_ptr(), mult_g, m_us);
1024            }
1025            if !mult_x_L.is_null() && n_us > 0 {
1026                std::ptr::copy_nonoverlapping(bridge_ref.final_z_l.as_ptr(), mult_x_L, n_us);
1027            }
1028            if !mult_x_U.is_null() && n_us > 0 {
1029                std::ptr::copy_nonoverlapping(bridge_ref.final_z_u.as_ptr(), mult_x_U, n_us);
1030            }
1031            status as Index
1032        })
1033    }
1034}
1035
1036/// Port of `SetIntermediateCallback`.
1037///
1038/// # Safety
1039///
1040/// `ipopt_problem` must be valid.
1041#[unsafe(no_mangle)]
1042pub unsafe extern "C" fn SetIntermediateCallback(
1043    ipopt_problem: IpoptProblem,
1044    intermediate_cb: Option<Intermediate_CB>,
1045) -> Bool {
1046    unsafe {
1047        if ipopt_problem.is_null() {
1048            return FALSE;
1049        }
1050        let info = &mut *ipopt_problem;
1051        info.intermediate_cb = intermediate_cb;
1052        TRUE
1053    }
1054}
1055
1056/// Port of `IpStdCInterface.cpp:GetIpoptCurrentIterate` (Ipopt 3.14+).
1057/// Designed to be called from inside an intermediate callback to
1058/// inspect `x`, the bound multipliers `z_L/z_U`, the constraint values
1059/// `g`, and the constraint multipliers `lambda` at the current
1060/// iterate.
1061///
1062/// All output buffers are optional — pass NULL to skip. `n` and `m`
1063/// must match the dimensions the problem was created with; mismatched
1064/// sizes cause the function to return `FALSE` without writing.
1065///
1066/// `scaled` keeps upstream's meaning (`TNLP::get_curr_iterate`, Ipopt
1067/// 3.14 `IpTNLP.cpp`), on both the interior-point and the active-set SQP
1068/// arm:
1069///
1070/// * `FALSE` — the user TNLP's unscaled space, the units `IpoptSolve`
1071///   writes back: objective, constraint and per-variable
1072///   (`SetIpoptProblemScaling` `x_scaling`) scaling all undone.
1073/// * `TRUE` — the algorithm's scaled space in the TNLP's shape: `x̃ = d ⊙ x`
1074///   (a fixed variable at its own value, as upstream's `ResortX` writes
1075///   it), `z̃ = obj·z/d` (at a fixed variable `obj·z`, `z_L`/`z_U` swapped
1076///   when `obj < 0`, upstream's manual rescale of `ResortBoundMultipliers`),
1077///   `g̃ = s ⊙ g` (row factor; equality right-hand sides scaled too) and
1078///   `λ̃ = obj·λ/s` ([`pounce_nlp::live_iterate::scaled_iterate`]). With
1079///   presolve on, the scaled values of the problem the algorithm solved,
1080///   lifted to the user's shape like the unscaled ones: a column or row the
1081///   algorithm holds carries its factor, one presolve removed carries
1082///   factor `1` (a multiplier there still carries `obj`).
1083///
1084/// Both arms answer from the iterate the algorithm materialises before
1085/// each fire ([`pounce_algorithm::intermediate::UserIterate`]), built by
1086/// the conversions `finalize_solution` applies — so at the last fire this
1087/// reads what `IpoptSolve` writes back. That includes the bound
1088/// multipliers of fixed variables, recovered from `∇f + Jᵀλ` as upstream's
1089/// `TNLPAdapter::ResortBoundMultipliers` does (not under
1090/// `fixed_variable_treatment=make_parameter_nodual`), and, with presolve on,
1091/// the rows and columns it removed.
1092///
1093/// Returns `FALSE` when called outside an active intermediate
1094/// callback (no live iterate to inspect).
1095///
1096/// # Safety
1097///
1098/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
1099/// when non-NULL, must hold at least the declared length.
1100#[allow(clippy::too_many_arguments)]
1101#[unsafe(no_mangle)]
1102pub unsafe extern "C" fn GetIpoptCurrentIterate(
1103    ipopt_problem: IpoptProblem,
1104    scaled: Bool,
1105    n: Index,
1106    x: *mut Number,
1107    z_l: *mut Number,
1108    z_u: *mut Number,
1109    m: Index,
1110    g: *mut Number,
1111    lambda: *mut Number,
1112) -> Bool {
1113    unsafe {
1114        if ipopt_problem.is_null() {
1115            return FALSE;
1116        }
1117        // During a complementarity solve the algorithm's iterate is the
1118        // lowered model's, not the caller's (gh#1147): no data, whatever
1119        // the request shape. Checked before the handle is read.
1120        if in_lowered_solve() {
1121            return FALSE;
1122        }
1123        let info = &*ipopt_problem;
1124        if n != info.n || m != info.m {
1125            return FALSE;
1126        }
1127        // Active-set SQP arm (gh#1015): no `IpoptData`/`IpoptCq`, so the
1128        // callback window carries a materialised user-space iterate instead.
1129        if let Some(r) = ip_intermediate::with_current_user_iterate(|it| {
1130            let scaled_it;
1131            let it = if scaled != FALSE {
1132                scaled_it = pounce_nlp::live_iterate::scaled_iterate(it);
1133                &scaled_it
1134            } else {
1135                it
1136            };
1137            let n_us = n as usize;
1138            let m_us = m as usize;
1139            let copy = |src: &[Number], dst: *mut Number, len: usize| -> bool {
1140                if dst.is_null() || len == 0 {
1141                    return true;
1142                }
1143                if src.len() != len {
1144                    return false;
1145                }
1146                std::ptr::copy_nonoverlapping(src.as_ptr(), dst, len);
1147                true
1148            };
1149            copy(&it.x, x, n_us)
1150                && copy(&it.z_l, z_l, n_us)
1151                && copy(&it.z_u, z_u, n_us)
1152                && copy(&it.g, g, m_us)
1153                && copy(&it.lambda, lambda, m_us)
1154        }) {
1155            return if r { TRUE } else { FALSE };
1156        }
1157        let result = ip_intermediate::with_current(|ctx| {
1158            // Snapshot the iterate handles and release the `data` borrow
1159            // before touching `cq`: several `IpoptCq` accessors
1160            // (`curr_c`, `curr_d`, …) evaluate through the NLP and take
1161            // `nlp.borrow_mut()` internally, so no `nlp`/`data` borrow may
1162            // be alive across them. Holding one here panicked
1163            // ("RefCell already borrowed") on every `g != NULL` call, and
1164            // a panic across this `extern "C"` boundary aborts the
1165            // process rather than returning `FALSE`.
1166            let curr = {
1167                let data = ctx.data.borrow();
1168                match data.curr.as_ref() {
1169                    Some(curr) => curr.clone(),
1170                    None => return false,
1171                }
1172            };
1173            let n_us = n as usize;
1174            let m_us = m as usize;
1175            // Per-variable `user-scaling` substitutes `x̃ = d ⊙ x` in a
1176            // `ScalingTnlp` below the NLP, so the iterate lifted here is
1177            // `x̃` / `z̃`. `finalize_solution` undoes that on the way out;
1178            // so must this, or the callback reads coordinates the user's
1179            // model never had. The multipliers go through the
1180            // `finalize_solution_*` lifts, not `pack_*_for_user`, for the
1181            // same reason: only those divide out `obj_scale_factor`, so a
1182            // solve with objective scaling (user or gradient-based) read
1183            // `z` and `λ` that many times too large here — the same
1184            // convention the SQP arm's iterate and `IpoptSolve` use.
1185            let d = ctx.nlp.borrow().variable_scaling();
1186            let d = d.as_deref();
1187            if !x.is_null() && n_us > 0 {
1188                let mut full_x = ctx.nlp.borrow().lift_x_to_full(&*curr.x);
1189                if full_x.len() != n_us {
1190                    return false;
1191                }
1192                pounce_nlp::scaling_tnlp::unscale_iterate(d, &mut full_x, &mut [], &mut []);
1193                std::ptr::copy_nonoverlapping(full_x.as_ptr(), x, n_us);
1194            }
1195            if !z_l.is_null() && n_us > 0 {
1196                let mut full = ctx.nlp.borrow().finalize_solution_z_l(&*curr.z_l);
1197                if full.len() != n_us {
1198                    return false;
1199                }
1200                pounce_nlp::scaling_tnlp::unscale_iterate(d, &mut [], &mut full, &mut []);
1201                std::ptr::copy_nonoverlapping(full.as_ptr(), z_l, n_us);
1202            }
1203            if !z_u.is_null() && n_us > 0 {
1204                let mut full = ctx.nlp.borrow().finalize_solution_z_u(&*curr.z_u);
1205                if full.len() != n_us {
1206                    return false;
1207                }
1208                pounce_nlp::scaling_tnlp::unscale_iterate(d, &mut [], &mut [], &mut full);
1209                std::ptr::copy_nonoverlapping(full.as_ptr(), z_u, n_us);
1210            }
1211            if !g.is_null() && m_us > 0 {
1212                // `curr_c` / `curr_d` re-enter the NLP mutably: evaluate
1213                // them first, *then* borrow `nlp` to pack the result.
1214                let (c, d) = {
1215                    let cq = ctx.cq.borrow();
1216                    (cq.curr_c(), cq.curr_d())
1217                };
1218                let full = ctx.nlp.borrow().pack_g_for_user(&*c, &*d);
1219                if full.len() != m_us {
1220                    return false;
1221                }
1222                std::ptr::copy_nonoverlapping(full.as_ptr(), g, m_us);
1223            }
1224            if !lambda.is_null() && m_us > 0 {
1225                let full = ctx
1226                    .nlp
1227                    .borrow()
1228                    .finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
1229                if full.len() != m_us {
1230                    return false;
1231                }
1232                std::ptr::copy_nonoverlapping(full.as_ptr(), lambda, m_us);
1233            }
1234            true
1235        });
1236        if result.unwrap_or(false) { TRUE } else { FALSE }
1237    }
1238}
1239
1240/// Port of `IpStdCInterface.cpp:GetIpoptCurrentViolations` (Ipopt 3.14+).
1241/// Same contract as [`GetIpoptCurrentIterate`]; returns `FALSE` when
1242/// called outside an active intermediate callback.
1243///
1244/// `scaled` keeps upstream's meaning (`TNLP::get_curr_violations`):
1245///
1246/// * `FALSE` — the user's problem, unscaled: objective, constraint and
1247///   per-variable scaling undone, full `n` with fixed variables, rows in
1248///   `g` order. Computed from the live iterate [`GetIpoptCurrentIterate`]
1249///   reads (so with presolve on, the user's own rows and columns) against
1250///   the declared bounds: `x_L_violation = max(0, x_L − x)`,
1251///   `compl_x_L = (x − x_L^alg)·z_L` with `x_L^alg` the bound the
1252///   algorithm's slack is measured to (declared, widened by
1253///   `bound_relax_factor`: upstream's `curr_compl_x_L / obj`, except under
1254///   per-variable user scaling, where pounce relaxes in the scaled frame and
1255///   Ipopt in the unscaled one), `grad_lag_x = ∇f + Jᵀλ − z_L + z_U`
1256///   (`0` at fixed variables, as upstream's `ResortX` leaves them; `∇f` and
1257///   `J` come from this problem's `eval_grad_f` / `eval_jac_g`, called only
1258///   when `grad_lag_x` is requested), per-row violation `|g − g_L|` /
1259///   `max(0, g_L − g, g − g_U)` and `compl_g` with upstream's signs. Both arms.
1260/// * `TRUE` — upstream's `get_curr_violations(scaled = true)`: the same
1261///   quantities in the algorithm's scaled space, each a fixed factor of its
1262///   unscaled counterpart ([`pounce_nlp::live_iterate::scale_violations`]):
1263///   bound violations `× d`, complementarities `× obj`, `grad_lag_x
1264///   × obj/d`, row violations `× s` — the factors `GetIpoptCurrentIterate`
1265///   (`scaled = TRUE`) applies. Both arms (the SQP arm's scaled space is
1266///   `d` and the `obj_scaling_factor` option: it runs no other scaling),
1267///   and under presolve, where a removed column or row carries factor 1.
1268///
1269/// # Safety
1270///
1271/// `ipopt_problem` must be a valid `IpoptProblem`. Each output buffer,
1272/// when non-NULL, must hold at least the declared length.
1273#[allow(clippy::too_many_arguments)]
1274#[unsafe(no_mangle)]
1275pub unsafe extern "C" fn GetIpoptCurrentViolations(
1276    ipopt_problem: IpoptProblem,
1277    scaled: Bool,
1278    n: Index,
1279    x_l_violation: *mut Number,
1280    x_u_violation: *mut Number,
1281    compl_x_l: *mut Number,
1282    compl_x_u: *mut Number,
1283    grad_lag_x: *mut Number,
1284    m: Index,
1285    nlp_constraint_violation: *mut Number,
1286    compl_g: *mut Number,
1287) -> Bool {
1288    unsafe {
1289        if ipopt_problem.is_null() {
1290            return FALSE;
1291        }
1292        // During a complementarity solve the algorithm's iterate is the
1293        // lowered model's, not the caller's (gh#1147): no data, whatever
1294        // the request shape. Checked before the handle is read.
1295        if in_lowered_solve() {
1296            return FALSE;
1297        }
1298        let info = &*ipopt_problem;
1299        if n != info.n || m != info.m {
1300            return FALSE;
1301        }
1302        let n_us = n as usize;
1303        let m_us = m as usize;
1304        let Some(it) = ip_intermediate::with_current_user_iterate(|it| it.clone()) else {
1305            return FALSE;
1306        };
1307        let bridge = CURRENT_BRIDGE.with(|c| c.get());
1308        if bridge.is_null() {
1309            return FALSE;
1310        }
1311        let bridge = &*bridge;
1312        if it.x.len() != n_us
1313            || it.z_l.len() != n_us
1314            || it.z_u.len() != n_us
1315            || it.g.len() != m_us
1316            || it.lambda.len() != m_us
1317        {
1318            return FALSE;
1319        }
1320        let grad_lag = if grad_lag_x.is_null() || n_us == 0 {
1321            None
1322        } else if it.grad_lag_x.len() == n_us {
1323            // The algorithm's own, from the evaluations it made: no
1324            // `eval_grad_f` / `eval_jac_g` call for it.
1325            Some(it.grad_lag_x.clone())
1326        } else {
1327            match bridge.user_grad_lag(&it) {
1328                Some(g) => Some(g),
1329                None => return FALSE,
1330            }
1331        };
1332        let bounds = pounce_nlp::live_iterate::DeclaredBounds {
1333            x_l: &bridge.x_l,
1334            x_u: &bridge.x_u,
1335            g_l: &bridge.g_l,
1336            g_u: &bridge.g_u,
1337            lower_inf: bridge.lower_inf,
1338            upper_inf: bridge.upper_inf,
1339        };
1340        let violations = Some(pounce_nlp::live_iterate::current_violations(
1341            &it,
1342            &bounds,
1343            grad_lag,
1344            bridge.zero_fixed_grad_lag,
1345            scaled != FALSE,
1346        ));
1347        let Some(v) = violations else {
1348            return FALSE;
1349        };
1350        let copy = |src: &[Number], dst: *mut Number, len: usize| -> bool {
1351            if dst.is_null() || len == 0 {
1352                return true;
1353            }
1354            if src.len() != len {
1355                return false;
1356            }
1357            std::ptr::copy_nonoverlapping(src.as_ptr(), dst, len);
1358            true
1359        };
1360        let ok = copy(&v.x_l_violation, x_l_violation, n_us)
1361            && copy(&v.x_u_violation, x_u_violation, n_us)
1362            && copy(&v.compl_x_l, compl_x_l, n_us)
1363            && copy(&v.compl_x_u, compl_x_u, n_us)
1364            && copy(&v.grad_lag_x, grad_lag_x, n_us)
1365            && copy(&v.constraint_violation, nlp_constraint_violation, m_us)
1366            && copy(&v.compl_g, compl_g, m_us);
1367        if ok { TRUE } else { FALSE }
1368    }
1369}
1370
1371/// Port of `IpStdCInterface.cpp:GetIpoptVersion` (Ipopt 3.14.18+).
1372/// Writes the pounce crate's `major.minor.patch` into the buffers.
1373/// Any pointer may be NULL to skip that component.
1374///
1375/// # Safety
1376///
1377/// Each non-NULL pointer must point at a writable `int`.
1378#[unsafe(no_mangle)]
1379pub unsafe extern "C" fn GetIpoptVersion(
1380    major: *mut c_int,
1381    minor: *mut c_int,
1382    release: *mut c_int,
1383) {
1384    unsafe {
1385        // Read from Cargo at compile time so the symbol always matches the
1386        // shipped binary. `unwrap_or(0)` keeps the function infallible if a
1387        // component is missing from the manifest (shouldn't happen in
1388        // practice — workspace manifest requires SemVer triples).
1389        let (mj, mn, pt) = parse_pkg_version(env!("CARGO_PKG_VERSION"));
1390        if !major.is_null() {
1391            *major = mj;
1392        }
1393        if !minor.is_null() {
1394            *minor = mn;
1395        }
1396        if !release.is_null() {
1397            *release = pt;
1398        }
1399    }
1400}
1401
1402fn parse_pkg_version(v: &str) -> (c_int, c_int, c_int) {
1403    let mut it = v.split('.').map(|s| s.parse::<c_int>().unwrap_or(0));
1404    (
1405        it.next().unwrap_or(0),
1406        it.next().unwrap_or(0),
1407        it.next().unwrap_or(0),
1408    )
1409}
1410
1411// ----------------------------------------------------------------------
1412// Pounce extensions: post-solve statistics accessors.
1413//
1414// Convenience accessors not present in upstream Ipopt's C API. Valid
1415// only after [`IpoptSolve`] has returned; calling them on a
1416// never-solved problem yields zero. They expose the same
1417// `SolveStatistics` data the Rust API surfaces via
1418// [`IpoptApplication::statistics`].
1419// ----------------------------------------------------------------------
1420
1421/// Number of IPM iterations in the most recent solve, or `0` if the
1422/// problem has not been solved yet.
1423///
1424/// # Safety
1425///
1426/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1427#[unsafe(no_mangle)]
1428pub unsafe extern "C" fn GetIpoptIterCount(ipopt_problem: IpoptProblem) -> Index {
1429    unsafe { last_stat(ipopt_problem, |s| s.iteration_count).unwrap_or(0) }
1430}
1431
1432/// Wall-clock solve time in seconds for the most recent solve, or
1433/// `0.0` if the problem has not been solved yet.
1434///
1435/// # Safety
1436///
1437/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1438#[unsafe(no_mangle)]
1439pub unsafe extern "C" fn GetIpoptSolveTime(ipopt_problem: IpoptProblem) -> Number {
1440    unsafe { last_stat(ipopt_problem, |s| s.total_wallclock_time_secs).unwrap_or(0.0) }
1441}
1442
1443/// Final primal infeasibility (max constraint violation) for the most
1444/// recent solve.
1445///
1446/// # Safety
1447///
1448/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1449#[unsafe(no_mangle)]
1450pub unsafe extern "C" fn GetIpoptPrimalInf(ipopt_problem: IpoptProblem) -> Number {
1451    unsafe { last_stat(ipopt_problem, |s| s.final_constr_viol).unwrap_or(0.0) }
1452}
1453
1454/// Final dual infeasibility (max gradient-of-Lagrangian norm) for the
1455/// most recent solve.
1456///
1457/// # Safety
1458///
1459/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1460#[unsafe(no_mangle)]
1461pub unsafe extern "C" fn GetIpoptDualInf(ipopt_problem: IpoptProblem) -> Number {
1462    unsafe { last_stat(ipopt_problem, |s| s.final_dual_inf).unwrap_or(0.0) }
1463}
1464
1465/// Final complementarity error for the most recent solve.
1466///
1467/// # Safety
1468///
1469/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
1470#[unsafe(no_mangle)]
1471pub unsafe extern "C" fn GetIpoptComplInf(ipopt_problem: IpoptProblem) -> Number {
1472    unsafe { last_stat(ipopt_problem, |s| s.final_compl).unwrap_or(0.0) }
1473}
1474
1475unsafe fn last_stat<T, F>(ipopt_problem: IpoptProblem, f: F) -> Option<T>
1476where
1477    F: FnOnce(&SolveStatistics) -> T,
1478{
1479    unsafe {
1480        if ipopt_problem.is_null() {
1481            return None;
1482        }
1483        (*ipopt_problem).last_solve.as_ref().map(|ls| f(&ls.stats))
1484    }
1485}
1486
1487/// Restoration-phase activity in the most recent solve. Each output
1488/// may be NULL to skip it; all yield zero before the first solve.
1489///
1490/// Solve-level, and still worth having now that the intermediate
1491/// callback also fires from restoration with `alg_mod = 1` (gh#645):
1492/// these counters answer "how much restoration did this solve do?"
1493/// without the caller having to install a callback and tally fires,
1494/// and they are the only source for the inner iteration count and
1495/// wall time.
1496///
1497/// # Safety
1498///
1499/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. Each
1500/// non-NULL output pointer must be writable.
1501#[unsafe(no_mangle)]
1502pub unsafe extern "C" fn GetPounceRestorationStats(
1503    ipopt_problem: IpoptProblem,
1504    calls: *mut Index,
1505    inner_iters: *mut Index,
1506    outer_iters: *mut Index,
1507    wall_secs: *mut Number,
1508) {
1509    unsafe {
1510        let stats = last_stat(ipopt_problem, |s| {
1511            (
1512                s.restoration_calls,
1513                s.restoration_inner_iters,
1514                s.restoration_outer_iters,
1515                s.restoration_wall_secs,
1516            )
1517        });
1518        let (c, i, o, w) = stats.unwrap_or((0, 0, 0, 0.0));
1519        if !calls.is_null() {
1520            *calls = c;
1521        }
1522        if !inner_iters.is_null() {
1523            *inner_iters = i;
1524        }
1525        if !outer_iters.is_null() {
1526            *outer_iters = o;
1527        }
1528        if !wall_secs.is_null() {
1529            *wall_secs = w;
1530        }
1531    }
1532}
1533
1534/// Finite-difference Hessian census from the most recent solve. Any
1535/// pointer may be NULL to skip that component.
1536///
1537/// All outputs are left at their "did not run" values on any solve that
1538/// was not `hessian_approximation=finite-difference`: `pattern_used`
1539/// is `-1` and the counts are `0`. `pattern_used` is `0` for the
1540/// declared pattern and `1` for the Jacobian-derived one, and it names
1541/// what the run **ended up with** — `declared` falls back to `jacobian`
1542/// when the TNLP declares no Hessian structure, and that fallback is
1543/// what the number is worth reading for.
1544///
1545/// # Safety
1546///
1547/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. Each
1548/// non-NULL output pointer must be writable.
1549#[unsafe(no_mangle)]
1550pub unsafe extern "C" fn GetPounceFdHessianStats(
1551    ipopt_problem: IpoptProblem,
1552    pattern_used: *mut Index,
1553    nnz: *mut Index,
1554    n: *mut Index,
1555    groups: *mut Index,
1556    rho_max: *mut Index,
1557    coloring_fell_back: *mut Index,
1558    objective_clique_widened: *mut Index,
1559) {
1560    unsafe {
1561        let stats = last_stat(ipopt_problem, |s| {
1562            (
1563                s.fd_hessian_pattern_used,
1564                s.fd_hessian_nnz,
1565                s.fd_hessian_n,
1566                s.fd_hessian_groups,
1567                s.fd_hessian_rho_max,
1568                if s.fd_hessian_coloring_fell_back {
1569                    1
1570                } else {
1571                    0
1572                },
1573                if s.fd_hessian_objective_clique_widened {
1574                    1
1575                } else {
1576                    0
1577                },
1578            )
1579        });
1580        let (p, nz, cols, g, r, f, w) = stats.unwrap_or((-1, 0, 0, 0, 0, 0, 0));
1581        if !pattern_used.is_null() {
1582            *pattern_used = p;
1583        }
1584        if !nnz.is_null() {
1585            *nnz = nz;
1586        }
1587        if !n.is_null() {
1588            *n = cols;
1589        }
1590        if !groups.is_null() {
1591            *groups = g;
1592        }
1593        if !rho_max.is_null() {
1594            *rho_max = r;
1595        }
1596        if !coloring_fell_back.is_null() {
1597            *coloring_fell_back = f;
1598        }
1599        if !objective_clique_widened.is_null() {
1600            *objective_clique_widened = w;
1601        }
1602    }
1603}
1604
1605/// C mirror of [`pounce_linsol::summary::LinearSolverSummary`], laid
1606/// out for `pounce.h`'s `PounceLinearSolverStats`. Optional fields
1607/// carry sentinels rather than a discriminant, because a plain struct
1608/// of scalars is what a C or C++ caller can consume without an
1609/// accessor per field: `NaN` for absent reals, `-1` for absent counts.
1610#[repr(C)]
1611#[derive(Debug, Clone, Copy)]
1612pub struct PounceLinearSolverStats {
1613    pub solver_name: [c_char; 32],
1614    pub n_factors: Index,
1615    pub n_pattern_reuse: Index,
1616    pub n_pattern_changes: Index,
1617    pub max_fill_ratio: Number,
1618    pub min_abs_pivot: Number,
1619    pub max_abs_pivot: Number,
1620    pub last_inertia_positive: Index,
1621    pub last_inertia_negative: Index,
1622    pub last_inertia_zero: Index,
1623    pub last_nnz_a: Index,
1624    pub last_nnz_l: Index,
1625}
1626
1627/// Post-mortem of the KKT linear solver for the most recent solve.
1628///
1629/// Reports what pounce already collects — factorization counts,
1630/// pattern reuse, fill, pivot range, final inertia. Timings are not
1631/// among them: pounce does not instrument the analyse / factor /
1632/// solve phases separately, so there is nothing honest to report and
1633/// the struct omits them rather than inventing zeros.
1634///
1635/// # Safety
1636///
1637/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `stats`,
1638/// when non-NULL, must point at a writable `PounceLinearSolverStats`.
1639#[unsafe(no_mangle)]
1640pub unsafe extern "C" fn GetPounceLinearSolverStats(
1641    ipopt_problem: IpoptProblem,
1642    stats: *mut PounceLinearSolverStats,
1643) -> Bool {
1644    unsafe {
1645        if ipopt_problem.is_null() || stats.is_null() {
1646            return FALSE;
1647        }
1648        let Some(summary) = (*ipopt_problem)
1649            .last_solve
1650            .as_ref()
1651            .and_then(|ls| ls.linear_solver.as_ref())
1652        else {
1653            return FALSE;
1654        };
1655        // Saturate rather than wrap: these are diagnostics, and a
1656        // count that does not fit an `Index` is better reported as the
1657        // largest representable one than as a negative.
1658        let count = |v: u64| Index::try_from(v).unwrap_or(Index::MAX);
1659        let size = |x: usize| Index::try_from(x).unwrap_or(Index::MAX);
1660        let opt_size = |v: Option<usize>| v.map_or(-1, size);
1661        let inertia = summary.last_inertia;
1662        let mut out = PounceLinearSolverStats {
1663            solver_name: [0; 32],
1664            n_factors: count(summary.n_factors),
1665            n_pattern_reuse: count(summary.n_pattern_reuse),
1666            n_pattern_changes: count(summary.n_pattern_changes),
1667            max_fill_ratio: summary.max_fill_ratio.unwrap_or(Number::NAN),
1668            min_abs_pivot: summary.min_abs_pivot.unwrap_or(Number::NAN),
1669            max_abs_pivot: summary.max_abs_pivot.unwrap_or(Number::NAN),
1670            last_inertia_positive: inertia.map_or(-1, |(p, _, _)| size(p)),
1671            last_inertia_negative: inertia.map_or(-1, |(_, n, _)| size(n)),
1672            last_inertia_zero: inertia.map_or(-1, |(_, _, z)| size(z)),
1673            last_nnz_a: opt_size(summary.last_nnz_a),
1674            last_nnz_l: opt_size(summary.last_nnz_l),
1675        };
1676        // Truncate on a byte boundary and always leave room for the
1677        // NUL; backend names are ASCII identifiers well under 31 bytes,
1678        // so this is a guard rather than an expected path.
1679        let name = summary.solver_name.as_bytes();
1680        let keep = name.len().min(out.solver_name.len() - 1);
1681        for (slot, b) in out.solver_name.iter_mut().zip(&name[..keep]) {
1682            *slot = *b as c_char;
1683        }
1684        *stats = out;
1685        TRUE
1686    }
1687}
1688
1689thread_local! {
1690    /// Option registry for handle-free [`GetPounceOptionType`] queries.
1691    ///
1692    /// Built from a throwaway application rather than by re-running the
1693    /// registration functions here, so it cannot drift from the set a
1694    /// real problem carries: it *is* that set, by construction.
1695    static DEFAULT_REGISTRY: Rc<pounce_common::reg_options::RegisteredOptions> =
1696        Rc::clone(IpoptApplication::new().registered_options());
1697}
1698
1699/// Which `AddIpopt*Option` setter a keyword expects.
1700///
1701/// Returns a `PounceOptionType` discriminant: 0 when the keyword is not
1702/// registered in this build, 1 number, 2 integer, 3 string. Lets a
1703/// caller forwarding options from an untyped source pick the setter
1704/// from pounce's registry instead of from the value's own type — the
1705/// difference between `tol: 1` reaching the solver as `1.0` and being
1706/// refused as an integer.
1707///
1708/// `ipopt_problem` may be NULL: option types are a property of the
1709/// build, not of a problem, and a caller deciding how to forward
1710/// options may not have created one yet (code generators, in
1711/// particular, have no handle at all).
1712///
1713/// # Safety
1714///
1715/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `keyword`,
1716/// when non-NULL, must be a NUL-terminated C string.
1717#[unsafe(no_mangle)]
1718pub unsafe extern "C" fn GetPounceOptionType(
1719    ipopt_problem: IpoptProblem,
1720    keyword: *const c_char,
1721) -> c_int {
1722    unsafe {
1723        if keyword.is_null() {
1724            return 0;
1725        }
1726        let Ok(name) = CStr::from_ptr(keyword).to_str() else {
1727            return 0;
1728        };
1729        let registered = if ipopt_problem.is_null() {
1730            DEFAULT_REGISTRY.with(|r| r.get_option(name))
1731        } else {
1732            (*ipopt_problem).app.registered_options().get_option(name)
1733        };
1734        let Some(opt) = registered else {
1735            return 0;
1736        };
1737        match opt.option_type {
1738            OptionType::OT_Number => 1,
1739            OptionType::OT_Integer => 2,
1740            OptionType::OT_String => 3,
1741            OptionType::OT_Unknown => 0,
1742        }
1743    }
1744}
1745
1746// ─────────────────────────────────────────────────────────────
1747// Pounce extension: SQP working-set warm-start C ABI (§7.2 of
1748// `docs/research/active-set-sqp-warm-start.md`).
1749//
1750// Three new entry points; all backward-compatible additions.
1751// No existing signature changes — existing cyipopt / JuMP /
1752// AMPL clients are unaffected.
1753// ─────────────────────────────────────────────────────────────
1754
1755fn bound_status_to_int(s: pounce_qp::BoundStatus) -> c_int {
1756    use pounce_qp::BoundStatus::*;
1757    match s {
1758        Inactive => POUNCE_WS_INACTIVE,
1759        AtLower => POUNCE_WS_AT_LOWER,
1760        AtUpper => POUNCE_WS_AT_UPPER,
1761        Fixed => POUNCE_WS_FIXED_OR_EQ,
1762    }
1763}
1764
1765/// Whether a staged variable status is a true claim about bounds `[lo, hi]`:
1766/// `Fixed` needs `lo == hi`, `AtLower`/`AtUpper` a finite side. Shared by
1767/// [`IpoptSetWarmStartWorkingSet`] and [`bounds::SetPounceBounds`], which
1768/// re-checks a staged set against the bounds it would install.
1769pub(crate) fn bound_status_consistent(s: pounce_qp::BoundStatus, lo: Number, hi: Number) -> bool {
1770    match s {
1771        pounce_qp::BoundStatus::Fixed => lo == hi,
1772        pounce_qp::BoundStatus::AtLower => lo > NLP_LOWER_BOUND_INF,
1773        pounce_qp::BoundStatus::AtUpper => hi < NLP_UPPER_BOUND_INF,
1774        pounce_qp::BoundStatus::Inactive => true,
1775    }
1776}
1777
1778/// The row counterpart of [`bound_status_consistent`]: `Equality` needs two
1779/// equal finite sides.
1780pub(crate) fn cons_status_consistent(s: pounce_qp::ConsStatus, lo: Number, hi: Number) -> bool {
1781    let lo_finite = lo > NLP_LOWER_BOUND_INF;
1782    let hi_finite = hi < NLP_UPPER_BOUND_INF;
1783    match s {
1784        pounce_qp::ConsStatus::Equality => lo_finite && hi_finite && lo == hi,
1785        pounce_qp::ConsStatus::AtLower => lo_finite,
1786        pounce_qp::ConsStatus::AtUpper => hi_finite,
1787        pounce_qp::ConsStatus::Inactive => true,
1788    }
1789}
1790
1791fn int_to_bound_status(v: c_int) -> Option<pounce_qp::BoundStatus> {
1792    use pounce_qp::BoundStatus::*;
1793    match v {
1794        POUNCE_WS_INACTIVE => Some(Inactive),
1795        POUNCE_WS_AT_LOWER => Some(AtLower),
1796        POUNCE_WS_AT_UPPER => Some(AtUpper),
1797        POUNCE_WS_FIXED_OR_EQ => Some(Fixed),
1798        _ => None,
1799    }
1800}
1801
1802fn cons_status_to_int(s: pounce_qp::ConsStatus) -> c_int {
1803    use pounce_qp::ConsStatus::*;
1804    match s {
1805        Inactive => POUNCE_WS_INACTIVE,
1806        AtLower => POUNCE_WS_AT_LOWER,
1807        AtUpper => POUNCE_WS_AT_UPPER,
1808        Equality => POUNCE_WS_FIXED_OR_EQ,
1809    }
1810}
1811
1812fn int_to_cons_status(v: c_int) -> Option<pounce_qp::ConsStatus> {
1813    use pounce_qp::ConsStatus::*;
1814    match v {
1815        POUNCE_WS_INACTIVE => Some(Inactive),
1816        POUNCE_WS_AT_LOWER => Some(AtLower),
1817        POUNCE_WS_AT_UPPER => Some(AtUpper),
1818        POUNCE_WS_FIXED_OR_EQ => Some(Equality),
1819        _ => None,
1820    }
1821}
1822
1823/// Retrieve the working set produced by the most recent SQP solve
1824/// (`algorithm = active-set-sqp`). Buffer sizes are `n` for
1825/// `bound_status_out` and `m` for `cons_status_out`. Pass `NULL`
1826/// for either to skip that side. Entry `i` is variable `i` / constraint
1827/// row `i` in the problem's own order (a variable fixed by
1828/// `x_L == x_U` reads `POUNCE_WS_FIXED_OR_EQ`), the same indexing
1829/// [`IpoptSetWarmStartWorkingSet`] takes.
1830///
1831/// Returns `TRUE` (1) on success, `FALSE` (0) if there is no
1832/// working set to retrieve (e.g. no SQP solve has run, the IPM
1833/// path was used, or the very first KKT check declared
1834/// optimality before solving any QP), if the most recent
1835/// [`IpoptSolve`] did not complete (refused, or failed before it
1836/// finished), or if complementarity pairs are declared on the
1837/// problem; nothing is written then.
1838///
1839/// # Safety
1840///
1841/// `ipopt_problem` must be a valid `IpoptProblem`. Output
1842/// buffers (when non-NULL) must be sized at least `n` and `m`
1843/// respectively.
1844#[unsafe(no_mangle)]
1845pub unsafe extern "C" fn IpoptGetWorkingSet(
1846    ipopt_problem: IpoptProblem,
1847    bound_status_out: *mut IpoptBoundStatus,
1848    cons_status_out: *mut IpoptConsStatus,
1849) -> Bool {
1850    unsafe {
1851        if ipopt_problem.is_null() {
1852            return FALSE;
1853        }
1854        let info = &*ipopt_problem;
1855        // No completed solve (none yet, or the last one was refused or
1856        // failed before it finished — `IpoptSolve` clears `last_solve` up
1857        // front), or pairs are declared, or the last solve ran the
1858        // complementarity driver on the lowered model (gh#1147): no working
1859        // set over the caller's rows belongs to the handle's current state,
1860        // and one left over from an earlier SQP solve must not read as if it
1861        // did.
1862        let Some(last) = info.last_solve.as_ref() else {
1863            return FALSE;
1864        };
1865        if last.mpcc.is_some() || !info.complementarity.is_empty() {
1866            return FALSE;
1867        }
1868        let ws = match info.app.last_sqp_working_set() {
1869            Some(w) => w,
1870            None => return FALSE,
1871        };
1872        // `last_sqp_working_set` is already in the caller's index spaces —
1873        // full `n` (fixed variables read `Fixed`) and `g` row order — so it
1874        // is copied positionally. This used to rebuild the SQP's
1875        // equalities-first order from the bounds and permute here, which
1876        // stopped matching once the application lifted bound statuses to
1877        // full `n` (gh#986): with any fixed variable the length check
1878        // below failed and this returned FALSE after a successful solve.
1879        let n = info.x_l.len();
1880        let m = info.g_l.len();
1881        if ws.constraints.len() != m || ws.bounds.len() != n {
1882            // The stored set does not match this problem's shape. Report
1883            // nothing rather than something mis-indexed.
1884            return FALSE;
1885        }
1886        if !bound_status_out.is_null() {
1887            for (i, &s) in ws.bounds.iter().enumerate() {
1888                *bound_status_out.add(i) = bound_status_to_int(s);
1889            }
1890        }
1891        if !cons_status_out.is_null() {
1892            for (i, &s) in ws.constraints.iter().enumerate() {
1893                *cons_status_out.add(i) = cons_status_to_int(s);
1894            }
1895        }
1896        TRUE
1897    }
1898}
1899
1900/// Supply a warm-start working set consumed by the next
1901/// [`IpoptSolve`] on this problem. Pass `NULL` for either side to
1902/// cold-start it. The caller-owned buffers are copied; reuse
1903/// across calls is safe. Indexed like [`IpoptGetWorkingSet`]: entry `i`
1904/// is variable `i` / constraint row `i` in the problem's own order.
1905///
1906/// Returns `TRUE` on success, `FALSE` on (a) NULL problem, (b)
1907/// an out-of-range status code in one of the buffers, or
1908/// (c) both inputs NULL (which would equal a no-op
1909/// — call [`IpoptClearWarmStartWorkingSet`] instead).
1910///
1911/// # Safety
1912///
1913/// `ipopt_problem` must be valid. `bound_status_in` (when
1914/// non-NULL) must be sized `n`; `cons_status_in` (when non-NULL)
1915/// must be sized `m`.
1916#[unsafe(no_mangle)]
1917pub unsafe extern "C" fn IpoptSetWarmStartWorkingSet(
1918    ipopt_problem: IpoptProblem,
1919    bound_status_in: *const IpoptBoundStatus,
1920    cons_status_in: *const IpoptConsStatus,
1921) -> Bool {
1922    unsafe {
1923        if ipopt_problem.is_null() {
1924            return FALSE;
1925        }
1926        if bound_status_in.is_null() && cons_status_in.is_null() {
1927            return FALSE;
1928        }
1929        let info = &mut *ipopt_problem;
1930        let n = info.n.max(0) as usize;
1931        let m = info.m.max(0) as usize;
1932        // The caller indexes by *their* rows and variables, and so does
1933        // `IpoptApplication::set_sqp_warm_start`: the solve maps the staged
1934        // set into the SQP's equalities-first, fixed-variables-removed
1935        // vectors through the problem's own classification, the inverse of
1936        // what `IpoptGetWorkingSet` reads out. Staged as given.
1937        // Validation is not just a range check. A status code says
1938        // where the point sits relative to a bound — but `Fixed` /
1939        // `Equality`, and `AtLower` / `AtUpper` against an infinite
1940        // side, additionally assert something about the *problem*:
1941        // that a variable has `x_l == x_u`, that a row has
1942        // `b_l == b_u`, that the bound being sat on exists at all.
1943        // Those are not guesses about the active set, they are claims
1944        // about the model, and the model is right here to check them
1945        // against.
1946        //
1947        // Accepting them unchecked converted a caller's mistake into a
1948        // silently wrong answer: `POUNCE_WS_FIXED_OR_EQ` on a variable
1949        // whose bounds differ pinned it, the solve over-constrained
1950        // itself, and a *convex* program came back with the wrong
1951        // optimum — the function having returned TRUE. Rejecting is
1952        // strictly better: the caller already handles FALSE, and this
1953        // function already returns it for an out-of-range code, so
1954        // TRUE reasonably reads as "your working set was accepted".
1955        let mut bounds = vec![pounce_qp::BoundStatus::Inactive; n];
1956        if !bound_status_in.is_null() {
1957            // Validate every entry the caller supplied, including those for
1958            // fixed variables that the internal problem drops: a wrong
1959            // claim is worth rejecting whether or not it would be used.
1960            for i in 0..n {
1961                let v = *bound_status_in.add(i);
1962                let Some(s) = int_to_bound_status(v) else {
1963                    return FALSE;
1964                };
1965                if !bound_status_consistent(s, info.x_l[i], info.x_u[i]) {
1966                    return FALSE;
1967                }
1968            }
1969            for (i, b) in bounds.iter_mut().enumerate() {
1970                // Already range-checked above.
1971                if let Some(s) = int_to_bound_status(*bound_status_in.add(i)) {
1972                    *b = s;
1973                }
1974            }
1975        }
1976        let mut constraints = vec![pounce_qp::ConsStatus::Inactive; m];
1977        if !cons_status_in.is_null() {
1978            for i in 0..m {
1979                let v = *cons_status_in.add(i);
1980                let Some(s) = int_to_cons_status(v) else {
1981                    return FALSE;
1982                };
1983                if !cons_status_consistent(s, info.g_l[i], info.g_u[i]) {
1984                    return FALSE;
1985                }
1986            }
1987            for (i, c) in constraints.iter_mut().enumerate() {
1988                if let Some(s) = int_to_cons_status(*cons_status_in.add(i)) {
1989                    *c = s;
1990                }
1991            }
1992        }
1993        // Stage the working set only. We do *not* know the
1994        // primal/dual iterate here, and we must not invent one:
1995        // `SqpAlgorithm::optimize_with_warm_start` treats a supplied
1996        // `SqpIterates` as *the* starting iterate and never consults
1997        // `get_starting_x` on that branch, so a placeholder `x` would
1998        // silently override the `x` buffer the caller hands to
1999        // `IpoptSolve`. Zeros here restarted every warm solve from
2000        // the origin — outside the bounds on any problem with
2001        // `x_l > 0` — and returned `Infeasible_Problem_Detected` at
2002        // iteration 0 (gh#484). `IpoptSolve` merges this working set
2003        // with the real starting point instead.
2004        info.pending_working_set = Some(pounce_qp::WorkingSet {
2005            bounds,
2006            constraints,
2007        });
2008        TRUE
2009    }
2010}
2011
2012/// Declare which variables enter the problem **nonlinearly** (gh#624).
2013///
2014/// This is the C-API face of Ipopt's `TNLP::get_number_of_nonlinear_variables`
2015/// / `get_list_of_nonlinear_variables` pair, which upstream exposes only
2016/// to C++ callers. It exists so a frontend that already knows the
2017/// structure of its model — CasADi's `pass_nonlinear_variables`, an
2018/// algebraic modeling language, a hand-written driver — can hand that
2019/// knowledge to pounce.
2020///
2021/// Effect is confined to the **limited-memory** Hessian: curvature is
2022/// approximated over the declared subset only, and the Hessian is
2023/// exactly zero for every other variable. Exact-Hessian solves ignore
2024/// the declaration entirely, and so does any solve that never calls
2025/// this function — the default remains "all variables are nonlinear".
2026/// The subset takes precedence over the `num_linear_variables` option,
2027/// matching Ipopt's own ordering.
2028///
2029/// `pos_nonlin_vars` holds `num_nonlin_vars` variable indices **in the
2030/// problem's index style** (the `index_style` passed to
2031/// [`CreateIpoptProblem`]). The subset may be arbitrary and
2032/// noncontiguous; order does not matter. Passing `num_nonlin_vars == n`
2033/// is equivalent to not calling this at all.
2034///
2035/// Returns `FALSE` (leaving any previous declaration untouched) on a
2036/// NULL problem, a negative or oversized count, a NULL array with a
2037/// positive count, or an index outside the problem's variable range.
2038///
2039/// Note the deliberate signature choice: the issue that requested this
2040/// suggested a `const Bool*` mask, but `Bool` in the Ipopt C API is a
2041/// C99 `bool`, and a *array* of those would be a per-element
2042/// data-layout contract that is easy to get wrong from a caller with a
2043/// different boolean width. The count-plus-index-list shape is the one
2044/// the TNLP callbacks already use.
2045///
2046/// # Safety
2047///
2048/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2049/// `pos_nonlin_vars`, when non-NULL, must point at `num_nonlin_vars`
2050/// readable `ipindex` values.
2051#[unsafe(no_mangle)]
2052pub unsafe extern "C" fn IpoptSetNonlinearVariables(
2053    ipopt_problem: IpoptProblem,
2054    num_nonlin_vars: Index,
2055    pos_nonlin_vars: *const Index,
2056) -> Bool {
2057    unsafe {
2058        if ipopt_problem.is_null() {
2059            return FALSE;
2060        }
2061        let info = &mut *ipopt_problem;
2062        if num_nonlin_vars < 0 || num_nonlin_vars > info.n {
2063            return FALSE;
2064        }
2065        if num_nonlin_vars > 0 && pos_nonlin_vars.is_null() {
2066            return FALSE;
2067        }
2068        let offset = if info.index_style == 1 { 1 } else { 0 };
2069        let raw = if num_nonlin_vars == 0 {
2070            &[][..]
2071        } else {
2072            std::slice::from_raw_parts(pos_nonlin_vars, num_nonlin_vars as usize)
2073        };
2074        // Validate before storing: a half-applied declaration would be
2075        // worse than a refused one.
2076        for &p in raw {
2077            let zero_based = p - offset;
2078            if zero_based < 0 || zero_based >= info.n {
2079                return FALSE;
2080            }
2081        }
2082        info.nonlinear_vars = Some(raw.to_vec());
2083        TRUE
2084    }
2085}
2086
2087/// Drop a subset declared by [`IpoptSetNonlinearVariables`], restoring
2088/// the default (every variable treated as nonlinear).
2089///
2090/// # Safety
2091///
2092/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2093#[unsafe(no_mangle)]
2094pub unsafe extern "C" fn IpoptClearNonlinearVariables(ipopt_problem: IpoptProblem) -> Bool {
2095    unsafe {
2096        if ipopt_problem.is_null() {
2097            return FALSE;
2098        }
2099        (*ipopt_problem).nonlinear_vars = None;
2100        TRUE
2101    }
2102}
2103
2104// ---------------------------------------------------------------------------
2105// Declared complementarity pairs (gh#1147)
2106// ---------------------------------------------------------------------------
2107
2108/// The bits [`SetPounceComplementarityPairs`]' `flags` may carry in this
2109/// version: none.
2110const KNOWN_PAIR_FLAGS: c_uint = 0;
2111
2112fn pair_error_code(kind: &PairErrorKind) -> c_int {
2113    match kind {
2114        PairErrorKind::OutOfRange => POUNCE_ERR_OUT_OF_RANGE,
2115        PairErrorKind::VarAlreadyPaired => POUNCE_ERR_VAR_ALREADY_PAIRED,
2116        PairErrorKind::RowAlreadyPaired => POUNCE_ERR_ROW_ALREADY_PAIRED,
2117        PairErrorKind::UnsupportedShape => POUNCE_ERR_SHAPE,
2118    }
2119}
2120
2121/// An index in the problem's index style, as a 0-based `usize` when it is
2122/// in `[0, len)`, else `usize::MAX`, which every caller refuses as out of
2123/// range. `checked_sub` keeps `i32::MIN` in Fortran
2124/// style from overflowing.
2125pub(crate) fn zero_based(p: Index, offset: Index, len: Index) -> usize {
2126    match p.checked_sub(offset) {
2127        Some(z) if z >= 0 && z < len => z as usize,
2128        _ => usize::MAX,
2129    }
2130}
2131
2132/// Replace the declared pairs with `new` (pairs in the problem's index
2133/// style), validated as a whole and on their own, and stored only if every
2134/// pair passes. On refusal the declared set is untouched and the error
2135/// names the refused pair's position in `new`.
2136///
2137/// Two passes, in the C API's check order (`errors.rs`): first every
2138/// pair's argument checks — range, variable not already in the batch, row
2139/// not already in the batch — then every pair's shape, by
2140/// [`classify_pair`]. Together they are the checks [`validate_pairs`](pounce_nlp::mpcc::validate_pairs) (the
2141/// solve's lowering) runs, but check by check over the batch where it goes
2142/// pair by pair, so a shape fault at pair 0 does not hide an argument
2143/// fault at pair 1, as in `SetPounceBounds`. `validate_pairs` is left as
2144/// it is because the lowering shares it.
2145///
2146/// O(n + m + P): one flag per variable and row. Fine once per batch; the
2147/// one-pair path is [`append_pair`].
2148fn replace_pairs(
2149    info: &mut IpoptProblemInfo,
2150    new: &[(Index, Index)],
2151) -> Result<(), (usize, PairErrorKind)> {
2152    let offset = if info.index_style == 1 { 1 } else { 0 };
2153    let candidate: Vec<ComplementarityPair> = new
2154        .iter()
2155        .map(|&(row, var)| ComplementarityPair {
2156            row: zero_based(row, offset, info.m),
2157            var: zero_based(var, offset, info.n),
2158        })
2159        .collect();
2160    let mut seen_var = vec![false; info.n as usize];
2161    let mut seen_row = vec![false; info.m as usize];
2162    for (k, p) in candidate.iter().enumerate() {
2163        if p.var == usize::MAX || p.row == usize::MAX {
2164            return Err((k, PairErrorKind::OutOfRange));
2165        }
2166        if std::mem::replace(&mut seen_var[p.var], true) {
2167            return Err((k, PairErrorKind::VarAlreadyPaired));
2168        }
2169        if std::mem::replace(&mut seen_row[p.row], true) {
2170            return Err((k, PairErrorKind::RowAlreadyPaired));
2171        }
2172    }
2173    for (k, p) in candidate.iter().enumerate() {
2174        if classify_pair(
2175            info.x_l[p.var],
2176            info.x_u[p.var],
2177            info.g_l[p.row],
2178            info.g_u[p.row],
2179        )
2180        .is_none()
2181        {
2182            return Err((k, PairErrorKind::UnsupportedShape));
2183        }
2184    }
2185    info.complementarity.replace(candidate);
2186    Ok(())
2187}
2188
2189/// Append one pair `(row, var)`, in the problem's index style, to the
2190/// declared set, after the checks [`validate_pairs`](pounce_nlp::mpcc::validate_pairs) runs on a pair, in its
2191/// order: range, variable not in a pair, row not in a pair, a shape
2192/// [`classify_pair`] accepts. O(1) expected (gh#1147 Stage 2): the stored
2193/// pairs are not re-validated, because every stored pair passed when it was
2194/// stored and every change to the bounds re-classifies the pairs it touches
2195/// ([`bounds`]), and the new pair's variable and row are in no stored pair.
2196fn append_pair(info: &mut IpoptProblemInfo, row: Index, var: Index) -> Result<(), PairErrorKind> {
2197    let offset = if info.index_style == 1 { 1 } else { 0 };
2198    let r = zero_based(row, offset, info.m);
2199    let v = zero_based(var, offset, info.n);
2200    if r == usize::MAX || v == usize::MAX {
2201        return Err(PairErrorKind::OutOfRange);
2202    }
2203    if info.complementarity.of_var(v).is_some() {
2204        return Err(PairErrorKind::VarAlreadyPaired);
2205    }
2206    if info.complementarity.of_row(r).is_some() {
2207        return Err(PairErrorKind::RowAlreadyPaired);
2208    }
2209    if classify_pair(info.x_l[v], info.x_u[v], info.g_l[r], info.g_u[r]).is_none() {
2210        return Err(PairErrorKind::UnsupportedShape);
2211    }
2212    info.complementarity
2213        .push(ComplementarityPair { row: r, var: v });
2214    Ok(())
2215}
2216
2217/// Declare one complementarity pair: constraint row `row` with variable
2218/// `var`, both **in the problem's index style** (gh#1147). The pair is in
2219/// POUNCE's native shapes — one-sided, one-sided plus a plain bound, or
2220/// mixed — exactly as Python's `Problem(complementarity=)` and the `.nl`
2221/// reader accept, and is checked at once by the same classifier the
2222/// solve's lowering runs (`pounce_nlp::mpcc::validate_pairs`), against the
2223/// handle's bounds — those given to [`CreateIpoptProblem`], as
2224/// [`AddPounceComplementsConstraint`] may have edited them — and the pairs
2225/// already declared.
2226///
2227/// Returns [`POUNCE_OK`] or a negative `POUNCE_ERR_*` code; a refused
2228/// pair leaves the declared set unchanged. Pairs persist on the handle
2229/// until [`SetPounceComplementarityPairs`] replaces them or
2230/// [`ClearPounceComplementarityPairs`] drops them.
2231///
2232/// # Safety
2233///
2234/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2235#[unsafe(no_mangle)]
2236pub unsafe extern "C" fn AddPounceComplementarityPair(
2237    ipopt_problem: IpoptProblem,
2238    row: Index,
2239    var: Index,
2240) -> c_int {
2241    ffi_guard(POUNCE_ERR_INTERNAL, || unsafe {
2242        if ipopt_problem.is_null() {
2243            return POUNCE_ERR_NULL;
2244        }
2245        match append_pair(&mut *ipopt_problem, row, var) {
2246            Ok(()) => POUNCE_OK,
2247            Err(kind) => pair_error_code(&kind),
2248        }
2249    })
2250}
2251
2252/// Replace the declared pairs with `n_pairs` pairs `(rows[k], vars[k])`,
2253/// indices in the problem's index style. All or nothing: the new set is
2254/// validated as a whole (on its own, not against the pairs it replaces)
2255/// and stored only if every pair passes; otherwise the previous set stays.
2256/// `n_pairs == 0` clears the set (the arrays may then be NULL).
2257///
2258/// `flags` must be 0. No bit is defined yet: it exists so that a later
2259/// relaxation (re-validating against the pairs being replaced, last-wins
2260/// duplicates, ...) can be asked for explicitly rather than change what an
2261/// existing call does. A bit this library does not define is refused with
2262/// [`POUNCE_ERR_FLAGS`], so an older library never silently ignores one.
2263/// Checked after the handle and before the count, as in
2264/// [`SetPounceBounds`].
2265///
2266/// `n_pairs < 0`, or above `min(n, m)` (more pairs than that must repeat a
2267/// variable or a row), is [`POUNCE_ERR_COUNT`], refused before `rows` and
2268/// `vars` are read.
2269///
2270/// Range and already-paired checks run over the whole batch before any
2271/// shape check (the shared check order, `errors.rs`).
2272///
2273/// `bad_index`, when non-NULL, receives the position in the batch of the
2274/// first refused pair (for a duplicate, the second occurrence), or `-1`
2275/// on success and on the errors that concern no single pair
2276/// ([`POUNCE_ERR_NULL`], [`POUNCE_ERR_FLAGS`], [`POUNCE_ERR_COUNT`]).
2277///
2278/// # Safety
2279///
2280/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `rows` and
2281/// `vars`, when non-NULL, must each point at `n_pairs` readable `ipindex`
2282/// values; `bad_index` must be NULL or writable.
2283#[unsafe(no_mangle)]
2284pub unsafe extern "C" fn SetPounceComplementarityPairs(
2285    ipopt_problem: IpoptProblem,
2286    n_pairs: Index,
2287    rows: *const Index,
2288    vars: *const Index,
2289    flags: c_uint,
2290    bad_index: *mut Index,
2291) -> c_int {
2292    ffi_guard(POUNCE_ERR_INTERNAL, || unsafe {
2293        let report = |k: Index| {
2294            if !bad_index.is_null() {
2295                *bad_index = k;
2296            }
2297        };
2298        report(-1);
2299        if ipopt_problem.is_null() {
2300            return POUNCE_ERR_NULL;
2301        }
2302        if flags & !KNOWN_PAIR_FLAGS != 0 {
2303            return POUNCE_ERR_FLAGS;
2304        }
2305        // More pairs than `min(n, m)` must repeat a variable or a row, so it
2306        // is a count fault, refused before the arrays are read: a garbage
2307        // count (an uninitialised `n_pairs`) would otherwise size the read
2308        // and the allocation below, and a failed allocation aborts the
2309        // process past `ffi_guard`. Same rule as `SetPounceBounds`.
2310        let max_pairs = (*ipopt_problem).n.min((*ipopt_problem).m);
2311        if n_pairs < 0 || n_pairs > max_pairs {
2312            return POUNCE_ERR_COUNT;
2313        }
2314        if n_pairs > 0 && (rows.is_null() || vars.is_null()) {
2315            return POUNCE_ERR_NULL;
2316        }
2317        let new: Vec<(Index, Index)> = if n_pairs == 0 {
2318            Vec::new()
2319        } else {
2320            let r = std::slice::from_raw_parts(rows, n_pairs as usize);
2321            let v = std::slice::from_raw_parts(vars, n_pairs as usize);
2322            r.iter().copied().zip(v.iter().copied()).collect()
2323        };
2324        match replace_pairs(&mut *ipopt_problem, &new) {
2325            Ok(()) => POUNCE_OK,
2326            Err((k, kind)) => {
2327                report(k as Index);
2328                pair_error_code(&kind)
2329            }
2330        }
2331    })
2332}
2333
2334/// Drop every declared complementarity pair. Returns `FALSE` only on a
2335/// NULL handle.
2336///
2337/// # Safety
2338///
2339/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2340#[unsafe(no_mangle)]
2341pub unsafe extern "C" fn ClearPounceComplementarityPairs(ipopt_problem: IpoptProblem) -> Bool {
2342    ffi_guard(FALSE, || unsafe {
2343        if ipopt_problem.is_null() {
2344            return FALSE;
2345        }
2346        (*ipopt_problem).complementarity.clear();
2347        TRUE
2348    })
2349}
2350
2351/// Number of declared complementarity pairs, or `-1` on a NULL handle.
2352///
2353/// # Safety
2354///
2355/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2356#[unsafe(no_mangle)]
2357pub unsafe extern "C" fn GetPounceComplementarityPairCount(ipopt_problem: IpoptProblem) -> Index {
2358    ffi_guard(-1, || unsafe {
2359        if ipopt_problem.is_null() {
2360            return -1;
2361        }
2362        (&*ipopt_problem).complementarity.len() as Index
2363    })
2364}
2365
2366/// Copy the declared pairs, in declaration order and **in the problem's
2367/// index style**, into `rows` and `vars` (each sized by
2368/// [`GetPounceComplementarityPairCount`]). Returns `FALSE`, writing
2369/// nothing, on a NULL handle or a NULL array while pairs are declared;
2370/// with no pairs it returns `TRUE` and the arrays may be NULL.
2371///
2372/// # Safety
2373///
2374/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `rows` and
2375/// `vars`, when non-NULL, must each have room for the declared count.
2376#[unsafe(no_mangle)]
2377pub unsafe extern "C" fn GetPounceComplementarityPairs(
2378    ipopt_problem: IpoptProblem,
2379    rows: *mut Index,
2380    vars: *mut Index,
2381) -> Bool {
2382    ffi_guard(FALSE, || unsafe {
2383        if ipopt_problem.is_null() {
2384            return FALSE;
2385        }
2386        let info = &*ipopt_problem;
2387        let k = info.complementarity.len();
2388        if k == 0 {
2389            return TRUE;
2390        }
2391        if rows.is_null() || vars.is_null() {
2392            return FALSE;
2393        }
2394        let offset = if info.index_style == 1 { 1 } else { 0 };
2395        let rows = std::slice::from_raw_parts_mut(rows, k);
2396        let vars = std::slice::from_raw_parts_mut(vars, k);
2397        for (i, p) in info.complementarity.iter().enumerate() {
2398            rows[i] = p.row as Index + offset;
2399            vars[i] = p.var as Index + offset;
2400        }
2401        TRUE
2402    })
2403}
2404
2405// ---------------------------------------------------------------------------
2406// MOI `[F, x] ∈ Complements` semantics (gh#1147)
2407// ---------------------------------------------------------------------------
2408
2409/// `x` boxed, `l < u` both finite: a native **mixed** pair was declared;
2410/// the row's bounds are unchanged (free).
2411pub const POUNCE_COMPLEMENTS_PAIR_MIXED: c_int = 0;
2412/// `x >= l` only: the row's lower bound was set to `0` (`F >= 0`) and a
2413/// native one-sided pair declared.
2414pub const POUNCE_COMPLEMENTS_PAIR_LOWER: c_int = 1;
2415/// `x <= u` only: the row's upper bound was set to `0` (`F <= 0`) and a
2416/// native one-sided pair declared.
2417pub const POUNCE_COMPLEMENTS_PAIR_UPPER: c_int = 2;
2418/// `x` free: both row bounds were set to `0` (`F = 0`); no pair.
2419pub const POUNCE_COMPLEMENTS_ROW_FIXED_NO_PAIR: c_int = 3;
2420/// `x` fixed (`l == u`): nothing changed, `F` stays free; no pair.
2421pub const POUNCE_COMPLEMENTS_VAR_FIXED_NO_PAIR: c_int = 4;
2422
2423pub(crate) fn complements_row(xl: Number, xu: Number) -> Option<ComplementsRow> {
2424    if xl.is_nan() || xu.is_nan() {
2425        return None;
2426    }
2427    match (xl > NLP_LOWER_BOUND_INF, xu < NLP_UPPER_BOUND_INF) {
2428        (true, true) if xl < xu => Some(ComplementsRow::Mixed),
2429        (true, true) if xl == xu => Some(ComplementsRow::VarFixed),
2430        (true, true) => None,
2431        (true, false) => Some(ComplementsRow::Lower),
2432        (false, true) => Some(ComplementsRow::Upper),
2433        (false, false) => Some(ComplementsRow::RowFixed),
2434    }
2435}
2436
2437impl ComplementsRow {
2438    /// The row bounds the table gives a free row, and whether it declares a
2439    /// pair.
2440    pub(crate) fn row_bounds(self, gl: Number, gu: Number) -> (Number, Number, bool) {
2441        match self {
2442            ComplementsRow::Mixed => (gl, gu, true),
2443            ComplementsRow::Lower => (0.0, gu, true),
2444            ComplementsRow::Upper => (gl, 0.0, true),
2445            ComplementsRow::RowFixed => (0.0, 0.0, false),
2446            ComplementsRow::VarFixed => (gl, gu, false),
2447        }
2448    }
2449
2450    fn code(self) -> c_int {
2451        match self {
2452            ComplementsRow::Mixed => POUNCE_COMPLEMENTS_PAIR_MIXED,
2453            ComplementsRow::Lower => POUNCE_COMPLEMENTS_PAIR_LOWER,
2454            ComplementsRow::Upper => POUNCE_COMPLEMENTS_PAIR_UPPER,
2455            ComplementsRow::RowFixed => POUNCE_COMPLEMENTS_ROW_FIXED_NO_PAIR,
2456            ComplementsRow::VarFixed => POUNCE_COMPLEMENTS_VAR_FIXED_NO_PAIR,
2457        }
2458    }
2459
2460    /// The native shape the edited bounds must classify as (pair rows).
2461    fn shape_matches(self, shape: Option<PairShape>) -> bool {
2462        matches!(
2463            (self, shape),
2464            (ComplementsRow::Mixed, Some(PairShape::Mixed { .. }))
2465                | (ComplementsRow::Lower, Some(PairShape::OneSided { .. }))
2466                | (ComplementsRow::Upper, Some(PairShape::OneSided { .. }))
2467        )
2468    }
2469}
2470
2471fn add_complements(info: &mut IpoptProblemInfo, row: Index, var: Index) -> c_int {
2472    let offset = if info.index_style == 1 { 1 } else { 0 };
2473    let (r, v) = (
2474        zero_based(row, offset, info.m),
2475        zero_based(var, offset, info.n),
2476    );
2477    if r == usize::MAX || v == usize::MAX {
2478        return POUNCE_ERR_OUT_OF_RANGE;
2479    }
2480    // Argument checks before semantic ones (the C API's check order,
2481    // `errors.rs`): a row an earlier call already used reads as already
2482    // paired, not as "not free" because that call bounded it.
2483    if info.complementarity.of_var(v).is_some() || info.complements_used.of_var(v).is_some() {
2484        return POUNCE_ERR_VAR_ALREADY_PAIRED;
2485    }
2486    if info.complementarity.of_row(r).is_some() || info.complements_used.of_row(r).is_some() {
2487        return POUNCE_ERR_ROW_ALREADY_PAIRED;
2488    }
2489    let (gl, gu) = (info.g_l[r], info.g_u[r]);
2490    if !(gl <= NLP_LOWER_BOUND_INF && gu >= NLP_UPPER_BOUND_INF) {
2491        return POUNCE_ERR_ROW_NOT_FREE;
2492    }
2493    let Some(table_row) = complements_row(info.x_l[v], info.x_u[v]) else {
2494        return POUNCE_ERR_VAR_BOUNDS;
2495    };
2496    let (new_gl, new_gu, declares) = table_row.row_bounds(gl, gu);
2497    debug_assert!(
2498        !declares
2499            || table_row.shape_matches(classify_pair(info.x_l[v], info.x_u[v], new_gl, new_gu)),
2500        "MOI table row {table_row:?} edits to a bound pair that is not its native shape"
2501    );
2502    // Tentative edit, then declare the pair (if any) through the path every
2503    // one-pair declaration takes. The row and the variable are in no pair,
2504    // so only the shape check can refuse, and the table row's edit makes
2505    // that shape native by construction; if it does refuse, the row is
2506    // restored rather than left half-edited.
2507    info.g_l[r] = new_gl;
2508    info.g_u[r] = new_gu;
2509    if declares && append_pair(info, row, var).is_err() {
2510        info.g_l[r] = gl;
2511        info.g_u[r] = gu;
2512        return POUNCE_ERR_INTERNAL;
2513    }
2514    info.complements_used.push(ComplementsUse {
2515        row: r,
2516        var: v,
2517        table: table_row,
2518        user_gl: gl,
2519        user_gu: gu,
2520    });
2521    table_row.code()
2522}
2523
2524/// Declare MOI's `[F, x] ∈ Complements` for constraint row `row` (`F`) and
2525/// variable `var` (`x`), both **in the problem's index style** (gh#1147).
2526///
2527/// MOI writes `F` as a free function and reads the sign it must have off
2528/// `x`'s bounds; POUNCE's native pairs read both sides' signs off their
2529/// bounds. This helper transcribes one into the other **by editing the
2530/// handle's copy of the row bounds**, then declares the native pair, as
2531/// the table says (bounds at or beyond `±1e19` are infinite):
2532///
2533/// | `x` bounds         | row edit            | pair            | returns |
2534/// |--------------------|---------------------|-----------------|---------|
2535/// | `l < u`, finite    | none                | native mixed    | [`POUNCE_COMPLEMENTS_PAIR_MIXED`] |
2536/// | `l` only           | `g_L = 0` (`F ≥ 0`) | one-sided       | [`POUNCE_COMPLEMENTS_PAIR_LOWER`] |
2537/// | `u` only           | `g_U = 0` (`F ≤ 0`) | one-sided       | [`POUNCE_COMPLEMENTS_PAIR_UPPER`] |
2538/// | free               | `g_L = g_U = 0`     | none (`F = 0`)  | [`POUNCE_COMPLEMENTS_ROW_FIXED_NO_PAIR`] |
2539/// | `l == u`           | none (`F` free)     | none            | [`POUNCE_COMPLEMENTS_VAR_FIXED_NO_PAIR`] |
2540///
2541/// The row must be free (else [`POUNCE_ERR_ROW_NOT_FREE`]:
2542/// use [`AddPounceComplementarityPair`] for a row that already has a
2543/// side). Checks, in order: NULL handle, range, variable then row not
2544/// already used (by a declared pair or by an earlier accepted call of this
2545/// helper), row free, the variable's bounds neither NaN nor crossed. A
2546/// refused call changes nothing. Bound edits are permanent on the handle:
2547/// [`ClearPounceComplementarityPairs`] and
2548/// [`SetPounceComplementarityPairs`] drop pairs, not edits.
2549///
2550/// # Safety
2551///
2552/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2553#[unsafe(no_mangle)]
2554pub unsafe extern "C" fn AddPounceComplementsConstraint(
2555    ipopt_problem: IpoptProblem,
2556    row: Index,
2557    var: Index,
2558) -> c_int {
2559    ffi_guard(POUNCE_ERR_INTERNAL, || unsafe {
2560        if ipopt_problem.is_null() {
2561            return POUNCE_ERR_NULL;
2562        }
2563        add_complements(&mut *ipopt_problem, row, var)
2564    })
2565}
2566
2567// ---------------------------------------------------------------------------
2568// Complementarity outcome getters (gh#1147)
2569//
2570// What the complementarity driver did on the most recent `IpoptSolve`: the
2571// same fields, through the same encodings, as the solve report's
2572// `complementarity` block (`MpccOutcome::report_info`) and Python's
2573// `info["complementarity"]`. Every getter answers "nothing" (`FALSE` / `-1`)
2574// when the most recent solve did not run the driver: no solve yet, a solve
2575// without pairs, a refused or failed solve, or a session solve.
2576// ---------------------------------------------------------------------------
2577
2578/// `certificate_class`: strongly stationary.
2579pub const POUNCE_CC_CLASS_S: c_int = 0;
2580/// `certificate_class`: weakly stationary only.
2581pub const POUNCE_CC_CLASS_W: c_int = 1;
2582/// `certificate_class`: not a stationary point of the model.
2583pub const POUNCE_CC_CLASS_NONE: c_int = 2;
2584/// `certificate_class`: not determined (the multiplier set is too large to
2585/// search).
2586pub const POUNCE_CC_CLASS_NOT_SEARCHED: c_int = 3;
2587/// `certificate_class`: no certificate (the model could not be evaluated at
2588/// the reported point).
2589pub const POUNCE_CC_CLASS_ABSENT: c_int = -1;
2590
2591/// `reported`: a formulation answered.
2592pub const POUNCE_CC_REPORTED_ANSWER: c_int = 0;
2593/// `reported`: a weakly stationary point, reported when none answered.
2594pub const POUNCE_CC_REPORTED_WEAK_FALLBACK: c_int = 1;
2595/// `reported`: a converged point with every product in tolerance, no
2596/// stationarity certified.
2597pub const POUNCE_CC_REPORTED_FEASIBLE_UNCERTIFIED: c_int = 2;
2598/// `reported`: no feasible point; the exact-product solve's verdict.
2599pub const POUNCE_CC_REPORTED_VERDICT: c_int = 3;
2600
2601/// `b_verdict`: B-stationary.
2602pub const POUNCE_CC_B_VERDICT_B: c_int = 0;
2603/// `b_verdict`: a branch descends.
2604pub const POUNCE_CC_B_VERDICT_NOT_B: c_int = 1;
2605/// `b_verdict`: not decided (the reason is `POUNCE_CC_TEXT_B_REASON`).
2606pub const POUNCE_CC_B_VERDICT_NOT_CHECKED: c_int = 2;
2607/// `b_verdict`: the check did not run.
2608pub const POUNCE_CC_B_VERDICT_ABSENT: c_int = -1;
2609
2610/// [`GetPounceComplementarityText`]: the driver's one-line account.
2611pub const POUNCE_CC_TEXT_SUMMARY: c_int = 0;
2612/// [`GetPounceComplementarityText`]: why the polish's point was not taken.
2613pub const POUNCE_CC_TEXT_POLISH_REASON: c_int = 1;
2614/// [`GetPounceComplementarityText`]: why the B-stationarity check was not
2615/// decided.
2616pub const POUNCE_CC_TEXT_B_REASON: c_int = 2;
2617
2618/// C mirror of the driver's [`MpccOutcome`](pounce_restoration::mpcc_driver::MpccOutcome)
2619/// for `pounce.h`'s `PounceComplementarityStats`. Field order is the ABI
2620/// (Pounce.jl mirrors structs field for field); `tests/c_examples_build_and_run.rs`
2621/// holds every offset to the C compiler's. Sentinels instead of
2622/// discriminants: `NaN` for a real that was not measured (or is not
2623/// finite, as the report's `null`), `-1` for a count or flag whose record
2624/// is absent.
2625///
2626/// Extensible (see `ExtensibleOut` in this crate): `struct_size` first, set by the
2627/// caller to `sizeof(PounceComplementarityStats)`; fields are only ever
2628/// appended.
2629#[repr(C)]
2630#[derive(Debug, Clone, Copy)]
2631pub struct PounceComplementarityStats {
2632    pub struct_size: Index,
2633    pub strategy: [c_char; 16],
2634    pub reported: Index,
2635    pub accepted_by: [c_char; 32],
2636    pub fallback: [c_char; 32],
2637    pub n_solves: Index,
2638    pub total_iterations: Index,
2639    pub max_abs_product: Number,
2640    pub certificate_class: Index,
2641    pub n_pairs: Index,
2642    pub n_biactive: Index,
2643    pub n_neither: Index,
2644    pub multipliers_unique: Index,
2645    pub residual: Number,
2646    pub resid_tol: Number,
2647    pub tight: Index,
2648    pub feasible: Index,
2649    pub primal_violation: Number,
2650    pub b_verdict: Index,
2651    pub b_n_biactive: Index,
2652    pub b_branches_solved: Index,
2653    pub b_enumerated: Index,
2654    pub escapes: Index,
2655    pub lifted: Index,
2656    pub natural_residual: Number,
2657    pub polish_ran: Index,
2658    pub polish_taken: Index,
2659    pub polish_residual_before: Number,
2660    pub polish_residual_after: Number,
2661    pub polish_threshold: Number,
2662}
2663
2664/// C mirror of one [`MpccSolveRecord`](pounce_restoration::mpcc_driver::MpccSolveRecord),
2665/// for `pounce.h`'s `PounceComplementaritySolve`. Extensible, like
2666/// [`PounceComplementarityStats`].
2667#[repr(C)]
2668#[derive(Debug, Clone, Copy)]
2669pub struct PounceComplementaritySolve {
2670    pub struct_size: Index,
2671    pub stage: [c_char; 32],
2672    pub relaxed: Index,
2673    pub tau: Number,
2674    pub status: Index,
2675    pub iterations: Index,
2676    pub max_abs_product: Number,
2677    pub seconds: Number,
2678}
2679
2680// SAFETY: both are `#[repr(C)]` with `struct_size: Index` first.
2681unsafe impl ExtensibleOut for PounceComplementarityStats {}
2682unsafe impl ExtensibleOut for PounceComplementaritySolve {}
2683
2684/// Copy `s` into a fixed C char array, truncated on a byte boundary so that
2685/// a NUL always fits; the rest of the array is zeroed.
2686fn copy_label<const N: usize>(dst: &mut [c_char; N], s: &str) {
2687    dst.fill(0);
2688    let keep = s.len().min(N.saturating_sub(1));
2689    for (slot, b) in dst.iter_mut().zip(&s.as_bytes()[..keep]) {
2690        *slot = *b as c_char;
2691    }
2692}
2693
2694/// A count as an `Index`, saturating rather than wrapping.
2695fn cc_count(v: usize) -> Index {
2696    Index::try_from(v).unwrap_or(Index::MAX)
2697}
2698
2699/// The report's `finite` rule, in C's terms: a non-finite or unmeasured
2700/// real reads `NaN` where the JSON reads `null`.
2701fn cc_real(v: Number) -> Number {
2702    if v.is_finite() { v } else { Number::NAN }
2703}
2704
2705fn cc_flag(b: bool) -> Index {
2706    Index::from(b)
2707}
2708
2709/// The struct [`GetPounceComplementarityStats`] fills, from the driver's
2710/// record. The encodings are `MpccOutcome::report_info`'s field for field
2711/// (strings become the `POUNCE_CC_*` codes, `null` becomes `NaN` or `-1`).
2712pub(crate) fn complementarity_stats(
2713    out: &pounce_restoration::mpcc_driver::MpccOutcome,
2714) -> PounceComplementarityStats {
2715    use pounce_algorithm::mpcc::{BVerdict, Reported, StationarityClass};
2716    let mut s = PounceComplementarityStats {
2717        struct_size: struct_size_of::<PounceComplementarityStats>(),
2718        strategy: [0; 16],
2719        reported: match out.reported {
2720            Reported::Answer(_) => POUNCE_CC_REPORTED_ANSWER,
2721            Reported::WeakFallback(_) => POUNCE_CC_REPORTED_WEAK_FALLBACK,
2722            Reported::FeasibleUncertified(_) => POUNCE_CC_REPORTED_FEASIBLE_UNCERTIFIED,
2723            Reported::Verdict => POUNCE_CC_REPORTED_VERDICT,
2724        },
2725        accepted_by: [0; 32],
2726        fallback: [0; 32],
2727        n_solves: cc_count(out.solves.len()),
2728        total_iterations: cc_count(out.total_iterations),
2729        max_abs_product: cc_real(out.max_abs_product),
2730        certificate_class: POUNCE_CC_CLASS_ABSENT,
2731        n_pairs: -1,
2732        n_biactive: -1,
2733        n_neither: -1,
2734        multipliers_unique: -1,
2735        residual: Number::NAN,
2736        resid_tol: Number::NAN,
2737        tight: -1,
2738        feasible: -1,
2739        primal_violation: Number::NAN,
2740        b_verdict: POUNCE_CC_B_VERDICT_ABSENT,
2741        b_n_biactive: -1,
2742        b_branches_solved: -1,
2743        b_enumerated: -1,
2744        escapes: cc_count(out.escapes),
2745        lifted: cc_count(out.lifted),
2746        natural_residual: out.natural_residual.map_or(Number::NAN, cc_real),
2747        polish_ran: 0,
2748        polish_taken: 0,
2749        polish_residual_before: Number::NAN,
2750        polish_residual_after: Number::NAN,
2751        polish_threshold: cc_real(out.polish_threshold),
2752    };
2753    copy_label(&mut s.strategy, out.strategy.name());
2754    if let Some(stage) = out.accepted_by {
2755        copy_label(&mut s.accepted_by, stage.label());
2756    }
2757    if let Some(stage) = out.fallback {
2758        copy_label(&mut s.fallback, stage.label());
2759    }
2760    if let Some(c) = &out.certificate {
2761        s.certificate_class = match c.class {
2762            StationarityClass::S => POUNCE_CC_CLASS_S,
2763            StationarityClass::W => POUNCE_CC_CLASS_W,
2764            StationarityClass::None => POUNCE_CC_CLASS_NONE,
2765            StationarityClass::NotSearched => POUNCE_CC_CLASS_NOT_SEARCHED,
2766        };
2767        s.n_pairs = cc_count(c.n_pairs);
2768        s.n_biactive = cc_count(c.n_biactive);
2769        s.n_neither = cc_count(c.n_neither);
2770        s.multipliers_unique = cc_flag(c.multipliers_unique);
2771        s.residual = cc_real(c.residual);
2772        s.resid_tol = cc_real(c.resid_tol);
2773        s.tight = cc_flag(c.tight);
2774        s.feasible = cc_flag(c.feasible);
2775        s.primal_violation = cc_real(c.primal_violation);
2776    }
2777    if let Some(b) = &out.b_check {
2778        s.b_verdict = match b.verdict {
2779            BVerdict::B => POUNCE_CC_B_VERDICT_B,
2780            BVerdict::NotB { .. } => POUNCE_CC_B_VERDICT_NOT_B,
2781            BVerdict::NotChecked(_) => POUNCE_CC_B_VERDICT_NOT_CHECKED,
2782        };
2783        s.b_n_biactive = cc_count(b.n_biactive);
2784        s.b_branches_solved = cc_count(b.branches_solved);
2785        s.b_enumerated = cc_flag(b.enumerated);
2786    }
2787    if let Some(p) = &out.polish {
2788        s.polish_ran = 1;
2789        s.polish_taken = cc_flag(p.taken);
2790        s.polish_residual_before = cc_real(p.residual_before);
2791        s.polish_residual_after = cc_real(p.residual_after);
2792    }
2793    s
2794}
2795
2796/// The record [`GetPounceComplementaritySolve`] fills for one lowered solve.
2797pub(crate) fn complementarity_solve(
2798    r: &pounce_restoration::mpcc_driver::MpccSolveRecord,
2799) -> PounceComplementaritySolve {
2800    let mut s = PounceComplementaritySolve {
2801        struct_size: struct_size_of::<PounceComplementaritySolve>(),
2802        stage: [0; 32],
2803        relaxed: cc_flag(r.tau.is_some()),
2804        tau: r.tau.unwrap_or(Number::NAN),
2805        status: r.status as Index,
2806        iterations: cc_count(r.iterations),
2807        max_abs_product: cc_real(r.max_abs_product),
2808        seconds: cc_real(r.seconds),
2809    };
2810    copy_label(&mut s.stage, r.stage.label());
2811    s
2812}
2813
2814/// The text [`GetPounceComplementarityText`] returns for `which`, or `None`
2815/// when that text is absent on this run (or `which` names none).
2816pub(crate) fn complementarity_text(
2817    out: &pounce_restoration::mpcc_driver::MpccOutcome,
2818    which: c_int,
2819) -> Option<String> {
2820    use pounce_algorithm::mpcc::BVerdict;
2821    match which {
2822        POUNCE_CC_TEXT_SUMMARY => Some(pounce_algorithm::mpcc::describe_outcome(
2823            out.reported,
2824            out.solves.len(),
2825            out.total_iterations,
2826            out.max_abs_product,
2827            out.certificate.as_ref(),
2828        )),
2829        POUNCE_CC_TEXT_POLISH_REASON => out.polish.as_ref().and_then(|p| p.reason.clone()),
2830        POUNCE_CC_TEXT_B_REASON => match out.b_check.as_ref().map(|b| &b.verdict) {
2831            Some(BVerdict::NotChecked(why)) => Some((*why).to_string()),
2832            _ => None,
2833        },
2834        _ => None,
2835    }
2836}
2837
2838/// The driver's record of the most recent solve, if that solve ran it.
2839unsafe fn last_mpcc<'a>(
2840    ipopt_problem: IpoptProblem,
2841) -> Option<&'a pounce_restoration::mpcc_driver::MpccOutcome> {
2842    unsafe {
2843        if ipopt_problem.is_null() {
2844            return None;
2845        }
2846        (*ipopt_problem)
2847            .last_solve
2848            .as_ref()
2849            .and_then(|ls| ls.mpcc.as_ref())
2850    }
2851}
2852
2853/// What the complementarity driver did on the most recent [`IpoptSolve`]
2854/// (gh#1147): the strategy, what was reported and by which formulation,
2855/// the iteration totals, the stationarity certificate, the B-stationarity
2856/// check and the polish — the solve report's `complementarity` block as a
2857/// struct of scalars. The caller sets `stats->struct_size =
2858/// sizeof(PounceComplementarityStats)` first. Returns `FALSE`, leaving
2859/// `stats` untouched, on a NULL argument, a `struct_size` smaller than this
2860/// release's size, before any solve, and when the most recent solve did not run
2861/// the driver (no pairs were declared, or it was refused or failed before
2862/// the driver returned). The record describes the most recent solve: clearing
2863/// the pairs afterwards does not erase it, the next solve does.
2864///
2865/// # Safety
2866///
2867/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `stats`, when
2868/// non-NULL, must point at a writable `PounceComplementarityStats` (at
2869/// least `struct_size` bytes when that is this release's size or more).
2870#[unsafe(no_mangle)]
2871pub unsafe extern "C" fn GetPounceComplementarityStats(
2872    ipopt_problem: IpoptProblem,
2873    stats: *mut PounceComplementarityStats,
2874) -> Bool {
2875    ffi_guard(FALSE, || unsafe {
2876        if stats.is_null() {
2877            return FALSE;
2878        }
2879        let Some(out) = last_mpcc(ipopt_problem) else {
2880            return FALSE;
2881        };
2882        // Bounded write of this release's size; FALSE, nothing written,
2883        // when `stats->struct_size` is short (see `write_extensible`).
2884        Bool::from(write_extensible(stats, &complementarity_stats(out)))
2885    })
2886}
2887
2888/// Lowered solve `k` (0-based, in the order the driver ran them, `k <
2889/// n_solves`) of the most recent driver solve; the caller sets
2890/// `solve->struct_size = sizeof(PounceComplementaritySolve)` first. Returns
2891/// `FALSE`, leaving `solve` untouched, on a NULL argument, a short
2892/// `struct_size`, a `k` out of range, and whenever
2893/// [`GetPounceComplementarityStats`] would.
2894///
2895/// # Safety
2896///
2897/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `solve`, when
2898/// non-NULL, must point at a writable `PounceComplementaritySolve` (at
2899/// least `struct_size` bytes when that is this release's size or more).
2900#[unsafe(no_mangle)]
2901pub unsafe extern "C" fn GetPounceComplementaritySolve(
2902    ipopt_problem: IpoptProblem,
2903    k: Index,
2904    solve: *mut PounceComplementaritySolve,
2905) -> Bool {
2906    ffi_guard(FALSE, || unsafe {
2907        if solve.is_null() {
2908            return FALSE;
2909        }
2910        let Some(out) = last_mpcc(ipopt_problem) else {
2911            return FALSE;
2912        };
2913        let Some(r) = usize::try_from(k).ok().and_then(|k| out.solves.get(k)) else {
2914            return FALSE;
2915        };
2916        Bool::from(write_extensible(solve, &complementarity_solve(r)))
2917    })
2918}
2919
2920/// One of the driver's free-form texts for the most recent driver solve,
2921/// with C's `snprintf` contract: writes at most `buflen - 1` bytes of it
2922/// plus a NUL into `buf` and returns its full length in bytes, without the
2923/// NUL — so `buf = NULL, buflen = 0` sizes the buffer, and a return `>=
2924/// buflen` means it was truncated. Returns `-1`, writing nothing, when
2925/// there is no such text: a NULL handle, a `which` that names none,
2926/// whenever [`GetPounceComplementarityStats`] would return `FALSE`, or a
2927/// text this run does not have (`POUNCE_CC_TEXT_POLISH_REASON` unless the
2928/// polish ran and was not taken; `POUNCE_CC_TEXT_B_REASON` unless the
2929/// B-stationarity check ran and was not decided). A NULL `buf` or a
2930/// `buflen <= 0` writes nothing and still returns the length.
2931///
2932/// # Safety
2933///
2934/// `ipopt_problem` must be a valid `IpoptProblem` or NULL. `buf`, when
2935/// non-NULL and `buflen > 0`, must have room for `buflen` bytes.
2936#[unsafe(no_mangle)]
2937pub unsafe extern "C" fn GetPounceComplementarityText(
2938    ipopt_problem: IpoptProblem,
2939    which: c_int,
2940    buf: *mut c_char,
2941    buflen: Index,
2942) -> Index {
2943    ffi_guard(-1, || unsafe {
2944        let Some(out) = last_mpcc(ipopt_problem) else {
2945            return -1;
2946        };
2947        let Some(text) = complementarity_text(out, which) else {
2948            return -1;
2949        };
2950        let bytes = text.as_bytes();
2951        if !buf.is_null() && buflen > 0 {
2952            let keep = bytes.len().min(buflen as usize - 1);
2953            let dst = std::slice::from_raw_parts_mut(buf, keep + 1);
2954            for (slot, b) in dst.iter_mut().zip(&bytes[..keep]) {
2955                *slot = *b as c_char;
2956            }
2957            dst[keep] = 0;
2958        }
2959        cc_count(bytes.len())
2960    })
2961}
2962
2963/// Drop any pending warm-start working set without solving. The
2964/// next [`IpoptSolve`] will cold-start.
2965///
2966/// # Safety
2967///
2968/// `ipopt_problem` must be a valid `IpoptProblem` or NULL.
2969#[unsafe(no_mangle)]
2970pub unsafe extern "C" fn IpoptClearWarmStartWorkingSet(ipopt_problem: IpoptProblem) -> Bool {
2971    unsafe {
2972        if ipopt_problem.is_null() {
2973            return FALSE;
2974        }
2975        (*ipopt_problem).pending_working_set = None;
2976        (*ipopt_problem).app.clear_sqp_warm_start();
2977        TRUE
2978    }
2979}
2980
2981/// Convenience one-shot: equivalent to
2982/// `IpoptSetWarmStartWorkingSet` + `IpoptSolve` +
2983/// `IpoptGetWorkingSet` in sequence. The input/output working-set
2984/// buffers are independent (so a caller can read back the new
2985/// working set into the same array used as input). Pass `NULL`
2986/// for any in/out buffer to skip that side.
2987///
2988/// Returns the `ApplicationReturnStatus` integer, identical to
2989/// [`IpoptSolve`].
2990///
2991/// # Safety
2992///
2993/// All pointer arguments follow the same contract as
2994/// `IpoptSolve` plus the working-set buffer sizes documented on
2995/// `IpoptSetWarmStartWorkingSet` / `IpoptGetWorkingSet`.
2996#[allow(clippy::too_many_arguments)]
2997#[unsafe(no_mangle)]
2998pub unsafe extern "C" fn IpoptSolveWarmStart(
2999    ipopt_problem: IpoptProblem,
3000    x: *mut Number,
3001    g: *mut Number,
3002    obj_val: *mut Number,
3003    mult_g: *mut Number,
3004    mult_x_L: *mut Number,
3005    mult_x_U: *mut Number,
3006    bound_status_in: *const IpoptBoundStatus,
3007    cons_status_in: *const IpoptConsStatus,
3008    bound_status_out: *mut IpoptBoundStatus,
3009    cons_status_out: *mut IpoptConsStatus,
3010    user_data: *mut c_void,
3011) -> Index {
3012    if ipopt_problem.is_null() {
3013        return ApplicationReturnStatus::InternalError as Index;
3014    }
3015    // Guard the working-set set/get helpers too. The inner `IpoptSolve` is
3016    // independently guarded, but a panic in the warm-start working-set
3017    // marshalling would otherwise still abort across `extern "C"`.
3018    ffi_guard(ApplicationReturnStatus::InternalError as Index, || unsafe {
3019        // Best-effort set. Errors here (e.g. bad status code) are
3020        // silently treated as cold-start; the caller can probe via
3021        // `IpoptSetWarmStartWorkingSet` directly if they need to
3022        // validate the input.
3023        if !bound_status_in.is_null() || !cons_status_in.is_null() {
3024            let _ = IpoptSetWarmStartWorkingSet(ipopt_problem, bound_status_in, cons_status_in);
3025        }
3026        let status = IpoptSolve(
3027            ipopt_problem,
3028            x,
3029            g,
3030            obj_val,
3031            mult_g,
3032            mult_x_L,
3033            mult_x_U,
3034            user_data,
3035        );
3036        // Copy a set out only when this solve completed: a refused solve
3037        // (pairs with a working set, gh#1147) leaves `last_solve` empty, and
3038        // the handle's last SQP set is then an earlier solve's.
3039        if (*ipopt_problem).last_solve.is_some() {
3040            let _ = IpoptGetWorkingSet(ipopt_problem, bound_status_out, cons_status_out);
3041        }
3042        status
3043    })
3044}
3045
3046thread_local! {
3047    /// The bridge whose `intermediate_callback` is running, for
3048    /// [`GetIpoptCurrentViolations`]; null outside that window.
3049    static CURRENT_BRIDGE: std::cell::Cell<*const CCallbackTnlp> =
3050        const { std::cell::Cell::new(std::ptr::null()) };
3051}
3052
3053/// `(nlp_lower_bound_inf, nlp_upper_bound_inf, zero_fixed_grad_lag)` for
3054/// the bridge fields of the same names.
3055pub(crate) fn bridge_violation_options(
3056    o: &pounce_common::options_list::OptionsList,
3057) -> (Number, Number, bool) {
3058    let num = |k: &str, d: Number| {
3059        o.get_numeric_value(k, "")
3060            .ok()
3061            .and_then(|(v, f)| f.then_some(v))
3062            .unwrap_or(d)
3063    };
3064    let relax = matches!(
3065        o.get_string_value("fixed_variable_treatment", "")
3066            .ok()
3067            .and_then(|(v, f)| f.then_some(v))
3068            .as_deref(),
3069        Some("relax_bounds")
3070    );
3071    (
3072        num("nlp_lower_bound_inf", NLP_LOWER_BOUND_INF),
3073        num("nlp_upper_bound_inf", NLP_UPPER_BOUND_INF),
3074        !relax,
3075    )
3076}
3077
3078thread_local! {
3079    /// `true` while the innermost [`IpoptSolve`] on this thread runs the
3080    /// complementarity driver (gh#1147). The live inspectors report no data
3081    /// then: the algorithm's iterate is the lowered model's — product rows
3082    /// appended, possibly lifted variables — not the caller's, and a prefix
3083    /// of it is not the caller's iterate either (a lifted row's bounds are
3084    /// rewritten).
3085    ///
3086    /// Per *solve*, not per thread: every `IpoptSolve` sets it for its own
3087    /// duration, so a pair-free solve of another problem started from inside
3088    /// a lowered solve's callback reads `false` (its iterate is its own) and
3089    /// the lowered solve reads `true` again once it returns.
3090    static IN_LOWERED_SOLVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3091}
3092
3093/// Sets [`IN_LOWERED_SOLVE`] for its lifetime, restoring the previous value
3094/// on drop (so nesting unwinds in order, and a panic caught by [`ffi_guard`]
3095/// cannot leave it set).
3096struct LoweredSolveGuard {
3097    prev: bool,
3098}
3099
3100impl LoweredSolveGuard {
3101    fn set(lowered: bool) -> Self {
3102        Self {
3103            prev: IN_LOWERED_SOLVE.with(|c| c.replace(lowered)),
3104        }
3105    }
3106}
3107
3108impl Drop for LoweredSolveGuard {
3109    fn drop(&mut self) {
3110        IN_LOWERED_SOLVE.with(|c| c.set(self.prev));
3111    }
3112}
3113
3114fn in_lowered_solve() -> bool {
3115    IN_LOWERED_SOLVE.with(|c| c.get())
3116}
3117
3118struct BridgeGuard {
3119    prev: *const CCallbackTnlp,
3120}
3121
3122impl BridgeGuard {
3123    fn install(bridge: &CCallbackTnlp) -> Self {
3124        let prev = CURRENT_BRIDGE.with(|c| c.replace(bridge as *const _));
3125        Self { prev }
3126    }
3127}
3128
3129impl Drop for BridgeGuard {
3130    fn drop(&mut self) {
3131        CURRENT_BRIDGE.with(|c| c.set(self.prev));
3132    }
3133}
3134
3135impl CCallbackTnlp {
3136    /// `∇f + Jᵀλ − z_L + z_U` at the user-space iterate `it`, through the
3137    /// user's own C callbacks (called directly, not through the TNLP the
3138    /// callback dispatch holds). `None` if a callback declines.
3139    fn user_grad_lag(&self, it: &ip_intermediate::UserIterate) -> Option<Vec<Number>> {
3140        let n = self.n as usize;
3141        let cb = self.eval_grad_f?;
3142        let mut grad = vec![0.0; n];
3143        let x = it.x.as_ptr() as *mut Number;
3144        if unsafe { cb(self.n, x, TRUE, grad.as_mut_ptr(), self.user_data) } == FALSE {
3145            return None;
3146        }
3147        let nnz = self.nele_jac.max(0) as usize;
3148        let (mut rows, mut cols, mut vals) = (vec![0; nnz], vec![0; nnz], vec![0.0; nnz]);
3149        if self.m > 0 && nnz > 0 {
3150            let jac = self.eval_jac_g?;
3151            let structure = unsafe {
3152                jac(
3153                    self.n,
3154                    x,
3155                    FALSE,
3156                    self.m,
3157                    self.nele_jac,
3158                    rows.as_mut_ptr(),
3159                    cols.as_mut_ptr(),
3160                    std::ptr::null_mut(),
3161                    self.user_data,
3162                )
3163            };
3164            let values = unsafe {
3165                jac(
3166                    self.n,
3167                    x,
3168                    FALSE,
3169                    self.m,
3170                    self.nele_jac,
3171                    std::ptr::null_mut(),
3172                    std::ptr::null_mut(),
3173                    vals.as_mut_ptr(),
3174                    self.user_data,
3175                )
3176            };
3177            if structure == FALSE || values == FALSE {
3178                return None;
3179            }
3180        }
3181        let off = if self.index_style == 1 { 1 } else { 0 };
3182        let to_usize = |v: &[Index]| -> Vec<usize> {
3183            v.iter()
3184                .map(|&k| usize::try_from(k - off).unwrap_or(usize::MAX))
3185                .collect()
3186        };
3187        Some(pounce_nlp::live_iterate::lagrangian_gradient(
3188            it,
3189            &grad,
3190            &to_usize(&rows),
3191            &to_usize(&cols),
3192            &vals,
3193        ))
3194    }
3195}
3196
3197/// Adapter that bridges the user-supplied C callback table to the
3198/// in-crate [`TNLP`] trait. Mirrors `Interfaces/IpStdInterfaceTNLP.cpp`
3199/// (`StdInterfaceTNLP`); each TNLP method forwards to the matching
3200/// `Eval_*_CB` and propagates `false` returns up so the algorithm
3201/// layer can map them to `Invalid_Number_Detected`.
3202///
3203/// Holds a snapshot of bounds and the initial `x`. After `optimize_tnlp`
3204/// finishes, `finalize_solution` is called by the algorithm layer; the
3205/// adapter records the final iterate in `final_*` fields, which the
3206/// outer [`IpoptSolve`] copies back into the caller's buffers.
3207pub(crate) struct CCallbackTnlp {
3208    pub(crate) n: Index,
3209    pub(crate) m: Index,
3210    pub(crate) nele_jac: Index,
3211    pub(crate) nele_hess: Index,
3212    pub(crate) index_style: Index,
3213    pub(crate) x_l: Vec<Number>,
3214    pub(crate) x_u: Vec<Number>,
3215    pub(crate) g_l: Vec<Number>,
3216    pub(crate) g_u: Vec<Number>,
3217    pub(crate) initial_x: Vec<Number>,
3218    /// The caller's `mult_g` buffer (length `m`), or NULL. Read only by
3219    /// [`TNLP::get_starting_point`] under `init_lambda`, i.e. when the
3220    /// solve was asked for a dual warm start (`warm_start_init_point=yes`)
3221    /// — upstream `StdInterfaceTNLP`'s `start_lam`. A caller that has not
3222    /// opted in may pass an uninitialized buffer, which is why this is a
3223    /// pointer read lazily under the flag rather than a snapshot taken
3224    /// up front. Valid for the whole `IpoptSolve` call: the buffer is
3225    /// overwritten with the solution only after the solve (and any
3226    /// second-opinion re-solve, which calls `get_starting_point` again)
3227    /// has finished.
3228    pub(crate) start_lambda: *const Number,
3229    /// The caller's `mult_x_L` buffer (length `n`), or NULL. Read under
3230    /// `init_z`; see [`CCallbackTnlp::start_lambda`].
3231    pub(crate) start_z_l: *const Number,
3232    /// The caller's `mult_x_U` buffer (length `n`), or NULL. Read under
3233    /// `init_z`; see [`CCallbackTnlp::start_lambda`].
3234    pub(crate) start_z_u: *const Number,
3235    pub(crate) eval_f: Option<Eval_F_CB>,
3236    pub(crate) eval_grad_f: Option<Eval_Grad_F_CB>,
3237    pub(crate) eval_g: Option<Eval_G_CB>,
3238    pub(crate) eval_jac_g: Option<Eval_Jac_G_CB>,
3239    pub(crate) eval_h: Option<Eval_H_CB>,
3240    pub(crate) user_data: *mut c_void,
3241    /// User-installed intermediate callback, copied at solve time so the
3242    /// TNLP-trait `intermediate_callback` impl can forward through to it.
3243    pub(crate) intermediate_cb: Option<Intermediate_CB>,
3244    /// Snapshot of user-provided scaling captured at solve time.
3245    pub(crate) user_scaling: Option<UserScaling>,
3246    /// Snapshot of the nonlinear-variable subset (gh#624), in the
3247    /// problem's index style.
3248    pub(crate) nonlinear_vars: Option<Vec<Index>>,
3249    /// `nlp_lower_bound_inf` / `nlp_upper_bound_inf` of the solve, for the
3250    /// violations `GetIpoptCurrentViolations` measures against `x_l` …
3251    /// `g_u` (a side at or beyond these is absent).
3252    pub(crate) lower_inf: Number,
3253    pub(crate) upper_inf: Number,
3254    /// Whether `grad_lag_x` reads `0` at fixed variables: true unless
3255    /// `fixed_variable_treatment=relax_bounds`, which keeps them in the
3256    /// problem (upstream: `ResortX(…, usefixedvals=false)`).
3257    pub(crate) zero_fixed_grad_lag: bool,
3258    pub(crate) final_status: Option<pounce_nlp::alg_types::SolverReturn>,
3259    pub(crate) final_x: Vec<Number>,
3260    pub(crate) final_z_l: Vec<Number>,
3261    pub(crate) final_z_u: Vec<Number>,
3262    pub(crate) final_g: Vec<Number>,
3263    pub(crate) final_lambda: Vec<Number>,
3264    pub(crate) final_obj: Number,
3265}
3266
3267impl TNLP for CCallbackTnlp {
3268    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3269        Some(NlpInfo {
3270            n: self.n as pounce_common::types::Index,
3271            m: self.m as pounce_common::types::Index,
3272            nnz_jac_g: self.nele_jac as pounce_common::types::Index,
3273            nnz_h_lag: self.nele_hess as pounce_common::types::Index,
3274            index_style: if self.index_style == 1 {
3275                IndexStyle::Fortran
3276            } else {
3277                IndexStyle::C
3278            },
3279        })
3280    }
3281
3282    /// gh#624 — serve the subset staged by
3283    /// [`IpoptSetNonlinearVariables`]. `-1` (no subset) keeps the
3284    /// upstream default of "all variables are nonlinear".
3285    fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
3286        match &self.nonlinear_vars {
3287            Some(v) => v.len() as pounce_common::types::Index,
3288            None => -1,
3289        }
3290    }
3291
3292    fn get_list_of_nonlinear_variables(
3293        &mut self,
3294        pos_nonlin_vars: &mut [pounce_common::types::Index],
3295    ) -> bool {
3296        let Some(v) = self.nonlinear_vars.as_ref() else {
3297            return false;
3298        };
3299        if v.len() != pos_nonlin_vars.len() {
3300            return false;
3301        }
3302        pos_nonlin_vars.copy_from_slice(v);
3303        true
3304    }
3305
3306    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3307        if !self.x_l.is_empty() {
3308            b.x_l.copy_from_slice(&self.x_l);
3309        }
3310        if !self.x_u.is_empty() {
3311            b.x_u.copy_from_slice(&self.x_u);
3312        }
3313        if !self.g_l.is_empty() {
3314            b.g_l.copy_from_slice(&self.g_l);
3315        }
3316        if !self.g_u.is_empty() {
3317            b.g_u.copy_from_slice(&self.g_u);
3318        }
3319        true
3320    }
3321
3322    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3323        if !self.initial_x.is_empty() {
3324            sp.x.copy_from_slice(&self.initial_x);
3325        }
3326        // Dual warm start, as upstream `StdInterfaceTNLP::get_starting_point`:
3327        // under `init_z` / `init_lambda` the caller's `mult_x_L` / `mult_x_U`
3328        // / `mult_g` buffers are the starting multipliers. Same sign
3329        // convention as the values `finalize_solution` writes back into those
3330        // buffers (L = f + lambda'g, z_L, z_U >= 0), so a solve's output feeds
3331        // the next solve's input unchanged.
3332        //
3333        // One deliberate departure: upstream fails the solve when a requested
3334        // buffer is NULL. Here NULL means "not supplied" and the slot is left
3335        // untouched, so `OrigIpoptNlp`'s NaN "unseeded" marker (gh#622) stands
3336        // and the warm-start initializer resolves it, rather than a primal-only
3337        // warm start becoming an error.
3338        let copy_in = |dst: &mut [Number], src: *const Number| {
3339            if !src.is_null() && !dst.is_empty() {
3340                // SAFETY: `src` is the caller's buffer of the documented
3341                // length (`n` for the bound multipliers, `m` for `mult_g`),
3342                // valid for the duration of the enclosing `IpoptSolve`.
3343                dst.copy_from_slice(unsafe { std::slice::from_raw_parts(src, dst.len()) });
3344            }
3345        };
3346        if sp.init_z {
3347            copy_in(sp.z_l, self.start_z_l);
3348            copy_in(sp.z_u, self.start_z_u);
3349        }
3350        if sp.init_lambda {
3351            copy_in(sp.lambda, self.start_lambda);
3352        }
3353        true
3354    }
3355
3356    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
3357        let Some(s) = self.user_scaling.as_ref() else {
3358            return false;
3359        };
3360        *req.obj_scaling = s.obj_scaling;
3361        if let Some(x) = s.x_scaling.as_ref() {
3362            if x.len() == req.x_scaling.len() {
3363                req.x_scaling.copy_from_slice(x);
3364                *req.use_x_scaling = true;
3365            }
3366        } else {
3367            *req.use_x_scaling = false;
3368        }
3369        if let Some(g) = s.g_scaling.as_ref() {
3370            if g.len() == req.g_scaling.len() {
3371                req.g_scaling.copy_from_slice(g);
3372                *req.use_g_scaling = true;
3373            }
3374        } else {
3375            *req.use_g_scaling = false;
3376        }
3377        true
3378    }
3379
3380    fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
3381        let cb = self.eval_f?;
3382        let mut obj = 0.0;
3383        let ok = unsafe {
3384            cb(
3385                self.n,
3386                x.as_ptr() as *mut Number,
3387                if new_x { TRUE } else { FALSE },
3388                &mut obj,
3389                self.user_data,
3390            )
3391        };
3392        if ok != FALSE { Some(obj) } else { None }
3393    }
3394
3395    fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
3396        let Some(cb) = self.eval_grad_f else {
3397            return false;
3398        };
3399        let ok = unsafe {
3400            cb(
3401                self.n,
3402                x.as_ptr() as *mut Number,
3403                if new_x { TRUE } else { FALSE },
3404                grad_f.as_mut_ptr(),
3405                self.user_data,
3406            )
3407        };
3408        ok != FALSE
3409    }
3410
3411    fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
3412        if self.m == 0 {
3413            return true;
3414        }
3415        let Some(cb) = self.eval_g else {
3416            return false;
3417        };
3418        let ok = unsafe {
3419            cb(
3420                self.n,
3421                x.as_ptr() as *mut Number,
3422                if new_x { TRUE } else { FALSE },
3423                self.m,
3424                g.as_mut_ptr(),
3425                self.user_data,
3426            )
3427        };
3428        ok != FALSE
3429    }
3430
3431    fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
3432        if self.m == 0 || self.nele_jac == 0 {
3433            return true;
3434        }
3435        let Some(cb) = self.eval_jac_g else {
3436            return false;
3437        };
3438        let x_ptr = x
3439            .map(|s| s.as_ptr() as *mut Number)
3440            .unwrap_or(std::ptr::null_mut());
3441        let ok = match mode {
3442            SparsityRequest::Structure { irow, jcol } => unsafe {
3443                cb(
3444                    self.n,
3445                    x_ptr,
3446                    if new_x { TRUE } else { FALSE },
3447                    self.m,
3448                    self.nele_jac,
3449                    irow.as_mut_ptr(),
3450                    jcol.as_mut_ptr(),
3451                    std::ptr::null_mut(),
3452                    self.user_data,
3453                )
3454            },
3455            SparsityRequest::Values { values } => unsafe {
3456                cb(
3457                    self.n,
3458                    x_ptr,
3459                    if new_x { TRUE } else { FALSE },
3460                    self.m,
3461                    self.nele_jac,
3462                    std::ptr::null_mut(),
3463                    std::ptr::null_mut(),
3464                    values.as_mut_ptr(),
3465                    self.user_data,
3466                )
3467            },
3468        };
3469        ok != FALSE
3470    }
3471
3472    fn eval_h(
3473        &mut self,
3474        x: Option<&[Number]>,
3475        new_x: bool,
3476        obj_factor: Number,
3477        lambda: Option<&[Number]>,
3478        new_lambda: bool,
3479        mode: SparsityRequest<'_>,
3480    ) -> bool {
3481        let Some(cb) = self.eval_h else {
3482            return false;
3483        };
3484        if self.nele_hess == 0 {
3485            return true;
3486        }
3487        let x_ptr = x
3488            .map(|s| s.as_ptr() as *mut Number)
3489            .unwrap_or(std::ptr::null_mut());
3490        let lambda_ptr = lambda
3491            .map(|s| s.as_ptr() as *mut Number)
3492            .unwrap_or(std::ptr::null_mut());
3493        let ok = match mode {
3494            SparsityRequest::Structure { irow, jcol } => unsafe {
3495                cb(
3496                    self.n,
3497                    x_ptr,
3498                    if new_x { TRUE } else { FALSE },
3499                    obj_factor,
3500                    self.m,
3501                    lambda_ptr,
3502                    if new_lambda { TRUE } else { FALSE },
3503                    self.nele_hess,
3504                    irow.as_mut_ptr(),
3505                    jcol.as_mut_ptr(),
3506                    std::ptr::null_mut(),
3507                    self.user_data,
3508                )
3509            },
3510            SparsityRequest::Values { values } => unsafe {
3511                cb(
3512                    self.n,
3513                    x_ptr,
3514                    if new_x { TRUE } else { FALSE },
3515                    obj_factor,
3516                    self.m,
3517                    lambda_ptr,
3518                    if new_lambda { TRUE } else { FALSE },
3519                    self.nele_hess,
3520                    std::ptr::null_mut(),
3521                    std::ptr::null_mut(),
3522                    values.as_mut_ptr(),
3523                    self.user_data,
3524                )
3525            },
3526        };
3527        ok != FALSE
3528    }
3529
3530    /// Only a registered `SetIntermediateCallback` reads the payload, and
3531    /// the `GetIpoptCurrent*` inspectors are valid only inside it (gh#965).
3532    fn wants_intermediate_callback(&self) -> bool {
3533        self.intermediate_cb.is_some()
3534    }
3535
3536    /// A registered callback may call `GetIpoptCurrentIterate` /
3537    /// `GetIpoptCurrentViolations`, and nothing says whether it will, so
3538    /// the iterate is materialised for every fire it receives.
3539    fn wants_current_iterate(&self) -> bool {
3540        self.intermediate_cb.is_some()
3541    }
3542
3543    fn intermediate_callback(
3544        &mut self,
3545        stats: pounce_nlp::tnlp::IterStats,
3546        _ip_data: &IpoptData,
3547        _ip_cq: &IpoptCq,
3548    ) -> bool {
3549        let Some(cb) = self.intermediate_cb else {
3550            return true;
3551        };
3552        // `GetIpoptCurrentViolations` evaluates `∇f` and `J` through this
3553        // bridge's C callbacks (the TNLP itself is mutably borrowed by the
3554        // dispatch that called us), so it needs the callbacks, `user_data`
3555        // and the declared bounds for exactly the duration of `cb`.
3556        let _bridge = BridgeGuard::install(self);
3557        let ok = unsafe {
3558            cb(
3559                stats.mode as Index,
3560                stats.iter as Index,
3561                stats.obj_value,
3562                stats.inf_pr,
3563                stats.inf_du,
3564                stats.mu,
3565                stats.d_norm,
3566                stats.regularization_size,
3567                stats.alpha_du,
3568                stats.alpha_pr,
3569                stats.ls_trials as Index,
3570                self.user_data,
3571            )
3572        };
3573        ok != FALSE
3574    }
3575
3576    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
3577        self.final_status = Some(sol.status);
3578        if !sol.x.is_empty() {
3579            self.final_x.copy_from_slice(sol.x);
3580        }
3581        if !sol.z_l.is_empty() {
3582            self.final_z_l.copy_from_slice(sol.z_l);
3583        }
3584        if !sol.z_u.is_empty() {
3585            self.final_z_u.copy_from_slice(sol.z_u);
3586        }
3587        if !sol.g.is_empty() {
3588            self.final_g.copy_from_slice(sol.g);
3589        }
3590        if !sol.lambda.is_empty() {
3591            self.final_lambda.copy_from_slice(sol.lambda);
3592        }
3593        self.final_obj = sol.obj_value;
3594    }
3595}
3596
3597/// Enable per-iteration history capture on the underlying
3598/// `IpoptApplication`. Must be called *before* [`IpoptSolve`] for the
3599/// trajectory to appear in the report written by
3600/// [`IpoptWriteSolveReport`]. Off by default — capturing each iterate
3601/// has a small per-iter cost the IPM core skips otherwise.
3602///
3603/// Returns `TRUE` on success, `FALSE` if `ipopt_problem` is NULL.
3604///
3605/// # Safety
3606///
3607/// `ipopt_problem` must be a valid handle returned by
3608/// [`CreateIpoptProblem`] (or `NULL`).
3609#[unsafe(no_mangle)]
3610pub unsafe extern "C" fn IpoptEnableIterHistory(ipopt_problem: IpoptProblem) -> Bool {
3611    if ipopt_problem.is_null() {
3612        return FALSE;
3613    }
3614    let info = unsafe { &mut *ipopt_problem };
3615    info.app.enable_iter_history();
3616    TRUE
3617}
3618
3619/// Write a `pounce.solve-report/v1` JSON file capturing the most
3620/// recent [`IpoptSolve`] result. `path` is a NUL-terminated UTF-8
3621/// filesystem path. `detail` is one of `"summary"` or `"full"`
3622/// (NUL-terminated); pass `NULL` for the default (`"summary"`).
3623///
3624/// When `detail = "full"` and [`IpoptEnableIterHistory`] was called
3625/// pre-solve, the per-iteration trajectory is embedded so that
3626/// downstream tools (`diagnose`, `find_stalls`, `convergence_trace`)
3627/// see the same trace the `pounce` CLI's `--json-output` path
3628/// produces. The input descriptor is recorded as `tnlp-direct`
3629/// because the cinterface receives callbacks rather than a file.
3630///
3631/// When that solve ran the complementarity driver (pairs were declared),
3632/// the report carries the `complementarity` block at either detail level,
3633/// built by the same conversion as the CLI's and Python's reports; it is
3634/// absent otherwise.
3635///
3636/// When that solve took the path without pairs and the second-opinion
3637/// ladder ran at least one rung, the report carries the `second_opinion`
3638/// block (gh#850) at either detail level, from the same conversion and
3639/// under the same condition as the CLI's and Python's; absent when no rung
3640/// ran, after a driver solve, and after a session solve.
3641///
3642/// Returns `TRUE` on a successful write, `FALSE` for NULL handle,
3643/// no prior solve, an invalid `detail`, a bad path, or an I/O error.
3644///
3645/// # Safety
3646///
3647/// `ipopt_problem` must be a valid handle; `path` must be a valid
3648/// NUL-terminated UTF-8 string; `detail` must be NULL or a valid
3649/// NUL-terminated UTF-8 string.
3650#[unsafe(no_mangle)]
3651pub unsafe extern "C" fn IpoptWriteSolveReport(
3652    ipopt_problem: IpoptProblem,
3653    path: *const c_char,
3654    detail: *const c_char,
3655) -> Bool {
3656    use pounce_solve_report::{
3657        InputDescriptor, ReportBuilder, ReportDetail, status_to_solve_result_num, write_report_file,
3658    };
3659
3660    // Guard the report build/write: it clones the retained iterate and runs
3661    // the `pounce-solve-report` serializer + file I/O, any of which could
3662    // panic on an unexpected state. A panic unwinding across `extern "C"`
3663    // aborts the embedding process; report `FALSE` instead. (See `ffi_guard`.)
3664    ffi_guard(FALSE, || unsafe {
3665        if ipopt_problem.is_null() || path.is_null() {
3666            return FALSE;
3667        }
3668        let info = &*ipopt_problem;
3669        let Some(last) = info.last_solve.as_ref() else {
3670            return FALSE;
3671        };
3672
3673        let Ok(path_str) = CStr::from_ptr(path).to_str() else {
3674            return FALSE;
3675        };
3676
3677        let detail_choice = if detail.is_null() {
3678            ReportDetail::Summary
3679        } else {
3680            let Ok(detail_str) = CStr::from_ptr(detail).to_str() else {
3681                return FALSE;
3682            };
3683            match ReportDetail::parse(detail_str) {
3684                Ok(d) => d,
3685                Err(_) => return FALSE,
3686            }
3687        };
3688
3689        let mut builder = ReportBuilder::new(detail_choice, InputDescriptor::TnlpDirect);
3690        builder.problem.n_variables = info.n;
3691        builder.problem.n_constraints = info.m;
3692        builder.problem.n_objectives = 1;
3693        builder.problem.nnz_jac_g = Some(info.nele_jac);
3694        builder.problem.nnz_h_lag = Some(info.nele_hess);
3695
3696        builder.solution.status = last.status;
3697        builder.solution.solve_result_num = status_to_solve_result_num(last.status);
3698        builder.solution.objective = last.final_obj;
3699        builder.solution.x = last.final_x.clone();
3700        builder.solution.lambda = last.final_lambda.clone();
3701
3702        builder.ingest_stats(&last.stats);
3703        if let Some(linsol) = last.linear_solver.clone() {
3704            builder.set_linear_solver_summary(linsol);
3705        }
3706        // Declared complementarity (gh#1147): what the driver did, through the
3707        // same conversion the CLI's `--json-output` and Python's `report_path`
3708        // use, so the three frontends write one block for one driver outcome.
3709        // `ingest_stats` above wrote the reported lowered solve's statistics;
3710        // the block carries every lowered solve and the true total. Absent for
3711        // a solve that did not run the driver (no pairs declared).
3712        if let Some(out) = &last.mpcc {
3713            builder.set_complementarity(out.report_info());
3714        }
3715        // gh#850: what the second-opinion ladder did, and what the *base*
3716        // solve did before it, under the CLI's and Python's `ran()` test and
3717        // through the same `report_info()`. `ingest_stats` above wrote the
3718        // promoted rung's iteration count as though it were the solve's;
3719        // without this block a rescued solve reads as a clean, fast one.
3720        // Absent when no rung ran, and on a driver solve, which does not run
3721        // the ladder (the CLI and Python skip it there too).
3722        if let Some(so) = &last.second_opinion
3723            && so.ran()
3724        {
3725            builder.set_second_opinion(so.report_info());
3726        }
3727
3728        let report = builder.finish();
3729        match write_report_file(std::path::Path::new(path_str), &report) {
3730            Ok(_) => TRUE,
3731            Err(_) => FALSE,
3732        }
3733    })
3734}
3735
3736#[cfg(test)]
3737mod tests {
3738    use super::*;
3739    use std::ffi::CString;
3740
3741    unsafe extern "C" fn dummy_eval_f(
3742        _n: Index,
3743        _x: *const Number,
3744        _new_x: Bool,
3745        _obj_value: *mut Number,
3746        _user_data: *mut c_void,
3747    ) -> Bool {
3748        TRUE
3749    }
3750    unsafe extern "C" fn dummy_eval_grad_f(
3751        _n: Index,
3752        _x: *const Number,
3753        _new_x: Bool,
3754        _grad_f: *mut Number,
3755        _user_data: *mut c_void,
3756    ) -> Bool {
3757        TRUE
3758    }
3759
3760    fn create_unconstrained() -> IpoptProblem {
3761        let xl = [-1.0; 4];
3762        let xu = [1.0; 4];
3763        unsafe {
3764            CreateIpoptProblem(
3765                4,
3766                xl.as_ptr(),
3767                xu.as_ptr(),
3768                0,
3769                std::ptr::null(),
3770                std::ptr::null(),
3771                0,
3772                10,
3773                0,
3774                Some(dummy_eval_f),
3775                None,
3776                Some(dummy_eval_grad_f),
3777                None,
3778                None,
3779            )
3780        }
3781    }
3782
3783    #[test]
3784    fn create_succeeds_for_unconstrained_problem() {
3785        let p = create_unconstrained();
3786        assert!(!p.is_null());
3787        unsafe { FreeIpoptProblem(p) };
3788    }
3789
3790    #[test]
3791    fn create_returns_null_on_missing_required_callbacks() {
3792        // error-path: CreateIpoptProblem
3793        let xl = [-1.0; 4];
3794        let xu = [1.0; 4];
3795        let p = unsafe {
3796            CreateIpoptProblem(
3797                4,
3798                xl.as_ptr(),
3799                xu.as_ptr(),
3800                0,
3801                std::ptr::null(),
3802                std::ptr::null(),
3803                0,
3804                10,
3805                0,
3806                None, // missing eval_f
3807                None,
3808                Some(dummy_eval_grad_f),
3809                None,
3810                None,
3811            )
3812        };
3813        assert!(p.is_null());
3814    }
3815
3816    #[test]
3817    fn create_returns_null_on_negative_n() {
3818        let p = unsafe {
3819            CreateIpoptProblem(
3820                -1,
3821                std::ptr::null(),
3822                std::ptr::null(),
3823                0,
3824                std::ptr::null(),
3825                std::ptr::null(),
3826                0,
3827                10,
3828                0,
3829                Some(dummy_eval_f),
3830                None,
3831                Some(dummy_eval_grad_f),
3832                None,
3833                None,
3834            )
3835        };
3836        assert!(p.is_null());
3837    }
3838
3839    #[test]
3840    fn create_returns_null_on_invalid_index_style() {
3841        let xl = [0.0; 1];
3842        let xu = [1.0; 1];
3843        let p = unsafe {
3844            CreateIpoptProblem(
3845                1,
3846                xl.as_ptr(),
3847                xu.as_ptr(),
3848                0,
3849                std::ptr::null(),
3850                std::ptr::null(),
3851                0,
3852                1,
3853                2, // valid values are 0 and 1
3854                Some(dummy_eval_f),
3855                None,
3856                Some(dummy_eval_grad_f),
3857                None,
3858                None,
3859            )
3860        };
3861        assert!(p.is_null());
3862    }
3863
3864    #[test]
3865    fn add_int_option_forwards_to_application() {
3866        let p = create_unconstrained();
3867        let key = CString::new("print_level").unwrap();
3868        let ok = unsafe { AddIpoptIntOption(p, key.as_ptr(), 5) };
3869        assert_eq!(ok, TRUE);
3870        let info = unsafe { &*p };
3871        let (level, found) = info
3872            .app
3873            .options()
3874            .get_integer_value("print_level", "")
3875            .unwrap();
3876        assert!(found);
3877        assert_eq!(level, 5);
3878        unsafe { FreeIpoptProblem(p) };
3879    }
3880
3881    #[test]
3882    fn add_str_option_with_invalid_key_returns_false() {
3883        let p = create_unconstrained();
3884        let key = CString::new("totally_unknown_option").unwrap();
3885        let val = CString::new("yes").unwrap();
3886        let ok = unsafe { AddIpoptStrOption(p, key.as_ptr(), val.as_ptr()) };
3887        assert_eq!(ok, FALSE);
3888        unsafe { FreeIpoptProblem(p) };
3889    }
3890
3891    #[test]
3892    fn add_options_on_null_problem_returns_false() {
3893        // error-path: AddIpoptIntOption
3894        // error-path: AddIpoptNumOption
3895        // error-path: AddIpoptStrOption
3896        let key = CString::new("print_level").unwrap();
3897        let v = CString::new("yes").unwrap();
3898        unsafe {
3899            assert_eq!(
3900                AddIpoptIntOption(std::ptr::null_mut(), key.as_ptr(), 5),
3901                FALSE
3902            );
3903            assert_eq!(
3904                AddIpoptNumOption(std::ptr::null_mut(), key.as_ptr(), 1.0),
3905                FALSE
3906            );
3907            assert_eq!(
3908                AddIpoptStrOption(std::ptr::null_mut(), key.as_ptr(), v.as_ptr()),
3909                FALSE
3910            );
3911        }
3912    }
3913
3914    unsafe extern "C" fn dummy_intermediate(
3915        _alg_mod: Index,
3916        _iter_count: Index,
3917        _obj_value: Number,
3918        _inf_pr: Number,
3919        _inf_du: Number,
3920        _mu: Number,
3921        _d_norm: Number,
3922        _regularization_size: Number,
3923        _alpha_du: Number,
3924        _alpha_pr: Number,
3925        _ls_trials: Index,
3926        _user_data: *mut c_void,
3927    ) -> Bool {
3928        TRUE
3929    }
3930
3931    #[test]
3932    fn set_intermediate_callback_stores_pointer() {
3933        let p = create_unconstrained();
3934        let ok = unsafe { SetIntermediateCallback(p, Some(dummy_intermediate)) };
3935        assert_eq!(ok, TRUE);
3936        let info = unsafe { &*p };
3937        assert!(info.intermediate_cb.is_some());
3938        unsafe { FreeIpoptProblem(p) };
3939    }
3940
3941    #[test]
3942    fn solve_returns_internal_error_on_null_problem() {
3943        // error-path: IpoptSolve
3944        let rc = unsafe {
3945            IpoptSolve(
3946                std::ptr::null_mut(),
3947                std::ptr::null_mut(),
3948                std::ptr::null_mut(),
3949                std::ptr::null_mut(),
3950                std::ptr::null_mut(),
3951                std::ptr::null_mut(),
3952                std::ptr::null_mut(),
3953                std::ptr::null_mut(),
3954            )
3955        };
3956        assert_eq!(rc, -199);
3957    }
3958
3959    #[test]
3960    fn free_null_is_safe() {
3961        // error-path: FreeIpoptProblem
3962        unsafe { FreeIpoptProblem(std::ptr::null_mut()) };
3963    }
3964
3965    // ---- End-to-end bridge: 1-D unconstrained quadratic ----
3966    //
3967    // f(x) = (x - 2)^2, no bounds, no constraints. Newton driver
3968    // converges in one step.
3969
3970    unsafe extern "C" fn quad_eval_f(
3971        _n: Index,
3972        x: *const Number,
3973        _new_x: Bool,
3974        obj_value: *mut Number,
3975        _user_data: *mut c_void,
3976    ) -> Bool {
3977        unsafe {
3978            let v = *x.offset(0);
3979            *obj_value = (v - 2.0) * (v - 2.0);
3980            TRUE
3981        }
3982    }
3983    unsafe extern "C" fn quad_eval_grad_f(
3984        _n: Index,
3985        x: *const Number,
3986        _new_x: Bool,
3987        grad: *mut Number,
3988        _user_data: *mut c_void,
3989    ) -> Bool {
3990        unsafe {
3991            let v = *x.offset(0);
3992            *grad.offset(0) = 2.0 * (v - 2.0);
3993            TRUE
3994        }
3995    }
3996    unsafe extern "C" fn quad_eval_h(
3997        _n: Index,
3998        _x: *const Number,
3999        _new_x: Bool,
4000        obj_factor: Number,
4001        _m: Index,
4002        _lambda: *const Number,
4003        _new_lambda: Bool,
4004        _nele_hess: Index,
4005        irow: *mut Index,
4006        jcol: *mut Index,
4007        values: *mut Number,
4008        _user_data: *mut c_void,
4009    ) -> Bool {
4010        unsafe {
4011            if !irow.is_null() && !jcol.is_null() && values.is_null() {
4012                *irow.offset(0) = 0;
4013                *jcol.offset(0) = 0;
4014            } else if irow.is_null() && jcol.is_null() && !values.is_null() {
4015                *values.offset(0) = 2.0 * obj_factor;
4016            } else {
4017                return FALSE;
4018            }
4019            TRUE
4020        }
4021    }
4022
4023    #[test]
4024    fn solve_drives_unconstrained_quadratic_through_bridge() {
4025        // Bounds wide open (kappa1 push won't move us off 0.0 since
4026        // |0| < 1e19, but the Newton step lands us at 2.0 anyway).
4027        let xl = [-1.0e20];
4028        let xu = [1.0e20];
4029        let p = unsafe {
4030            CreateIpoptProblem(
4031                1,
4032                xl.as_ptr(),
4033                xu.as_ptr(),
4034                0,
4035                std::ptr::null(),
4036                std::ptr::null(),
4037                0,
4038                1,
4039                0,
4040                Some(quad_eval_f),
4041                None,
4042                Some(quad_eval_grad_f),
4043                None,
4044                Some(quad_eval_h),
4045            )
4046        };
4047        assert!(!p.is_null());
4048        let mut x = [0.0_f64];
4049        let mut obj = 0.0_f64;
4050        let rc = unsafe {
4051            IpoptSolve(
4052                p,
4053                x.as_mut_ptr(),
4054                std::ptr::null_mut(),
4055                &mut obj,
4056                std::ptr::null_mut(),
4057                std::ptr::null_mut(),
4058                std::ptr::null_mut(),
4059                std::ptr::null_mut(),
4060            )
4061        };
4062        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4063        assert!((x[0] - 2.0).abs() < 1e-6, "x[0] = {}", x[0]);
4064        assert!(obj.abs() < 1e-10, "obj = {}", obj);
4065        unsafe { FreeIpoptProblem(p) };
4066    }
4067
4068    /// F5: `IpoptSolve` invalidates the retained `last_solve` stats **up
4069    /// front**, so a solve that bails — or whose pounce-internal panic
4070    /// `ffi_guard` catches (returning `Internal_Error`) — does not leave the
4071    /// post-solve accessors (`GetIpoptIterCount`, `IpoptWriteSolveReport`, …)
4072    /// silently reporting the *previous* solve's stats.
4073    ///
4074    /// A caught panic can't be injected deterministically through the public
4075    /// C ABI (a panic in a user `extern "C"` callback aborts at its own
4076    /// boundary; see `ffi_guard`). We drive the equivalent control-flow shape:
4077    /// after a successful solve we corrupt `n` to a negative value so the next
4078    /// `IpoptSolve` returns `InvalidProblemDefinition` from inside the guarded
4079    /// body **without** reaching the trailing `last_solve = Some(..)` write —
4080    /// exactly where a caught panic also bails. The up-front clear makes the
4081    /// accessor report "no data" (0) in both cases rather than stale data.
4082    #[test]
4083    fn stale_stats_cleared_when_resolve_bails() {
4084        let xl = [-1.0e20];
4085        let xu = [1.0e20];
4086        let p = unsafe {
4087            CreateIpoptProblem(
4088                1,
4089                xl.as_ptr(),
4090                xu.as_ptr(),
4091                0,
4092                std::ptr::null(),
4093                std::ptr::null(),
4094                0,
4095                1,
4096                0,
4097                Some(quad_eval_f),
4098                None,
4099                Some(quad_eval_grad_f),
4100                None,
4101                Some(quad_eval_h),
4102            )
4103        };
4104        assert!(!p.is_null());
4105
4106        let mut x = [0.0_f64];
4107        let mut obj = 0.0_f64;
4108        let rc = unsafe {
4109            IpoptSolve(
4110                p,
4111                x.as_mut_ptr(),
4112                std::ptr::null_mut(),
4113                &mut obj,
4114                std::ptr::null_mut(),
4115                std::ptr::null_mut(),
4116                std::ptr::null_mut(),
4117                std::ptr::null_mut(),
4118            )
4119        };
4120        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4121        // The successful solve recorded real stats.
4122        let iters_after_success = unsafe { GetIpoptIterCount(p) };
4123        assert!(
4124            iters_after_success >= 1,
4125            "a converged solve should record >=1 iteration, got {iters_after_success}"
4126        );
4127        assert!(unsafe { (*p).last_solve.is_some() });
4128
4129        // Corrupt the problem so the next solve bails early in the guarded body
4130        // (the same place a caught panic would land) without recording stats.
4131        unsafe { (*p).n = -1 };
4132        let mut x2 = [0.0_f64];
4133        let rc2 = unsafe {
4134            IpoptSolve(
4135                p,
4136                x2.as_mut_ptr(),
4137                std::ptr::null_mut(),
4138                std::ptr::null_mut(),
4139                std::ptr::null_mut(),
4140                std::ptr::null_mut(),
4141                std::ptr::null_mut(),
4142                std::ptr::null_mut(),
4143            )
4144        };
4145        assert_eq!(
4146            rc2,
4147            ApplicationReturnStatus::InvalidProblemDefinition as Index
4148        );
4149
4150        // Post-fix: the up-front invalidation cleared the retained stats, so
4151        // the accessor reports "no data" (0), not the previous iteration count.
4152        // Pre-fix this returned `iters_after_success` (stale).
4153        assert!(
4154            unsafe { (*p).last_solve.is_none() },
4155            "a bailed re-solve must clear stale last_solve (F5)"
4156        );
4157        assert_eq!(
4158            unsafe { GetIpoptIterCount(p) },
4159            0,
4160            "stale iteration count must not survive a bailed re-solve (F5)"
4161        );
4162
4163        unsafe { FreeIpoptProblem(p) };
4164    }
4165
4166    #[test]
4167    fn solve_invalid_problem_definition_when_x_null() {
4168        let p = create_unconstrained();
4169        let rc = unsafe {
4170            IpoptSolve(
4171                p,
4172                std::ptr::null_mut(), // x null but n > 0
4173                std::ptr::null_mut(),
4174                std::ptr::null_mut(),
4175                std::ptr::null_mut(),
4176                std::ptr::null_mut(),
4177                std::ptr::null_mut(),
4178                std::ptr::null_mut(),
4179            )
4180        };
4181        assert_eq!(
4182            rc,
4183            ApplicationReturnStatus::InvalidProblemDefinition as Index
4184        );
4185        unsafe { FreeIpoptProblem(p) };
4186    }
4187
4188    // ---- New entry points (issue #19) ----
4189
4190    #[test]
4191    fn get_version_writes_pkg_version() {
4192        let (mut mj, mut mn, mut pt) = (-1, -1, -1);
4193        unsafe { GetIpoptVersion(&mut mj, &mut mn, &mut pt) };
4194        let expected = parse_pkg_version(env!("CARGO_PKG_VERSION"));
4195        assert_eq!((mj, mn, pt), expected);
4196    }
4197
4198    #[test]
4199    fn get_version_tolerates_null_buffers() {
4200        // error-path: GetIpoptVersion
4201        // None of these should crash.
4202        unsafe {
4203            GetIpoptVersion(
4204                std::ptr::null_mut(),
4205                std::ptr::null_mut(),
4206                std::ptr::null_mut(),
4207            )
4208        };
4209    }
4210
4211    #[test]
4212    fn set_scaling_stores_user_supplied_arrays() {
4213        let p = create_unconstrained();
4214        let xs = [2.0, 3.0, 4.0, 5.0];
4215        let ok = unsafe { SetIpoptProblemScaling(p, 7.0, xs.as_ptr(), std::ptr::null()) };
4216        assert_eq!(ok, TRUE);
4217        let info = unsafe { &*p };
4218        let s = info.user_scaling.as_ref().unwrap();
4219        assert_eq!(s.obj_scaling, 7.0);
4220        assert_eq!(s.x_scaling.as_deref(), Some(&xs[..]));
4221        assert!(s.g_scaling.is_none());
4222        unsafe { FreeIpoptProblem(p) };
4223    }
4224
4225    #[test]
4226    fn set_scaling_on_null_problem_returns_false() {
4227        // error-path: SetIpoptProblemScaling
4228        let ok = unsafe {
4229            SetIpoptProblemScaling(
4230                std::ptr::null_mut(),
4231                1.0,
4232                std::ptr::null(),
4233                std::ptr::null(),
4234            )
4235        };
4236        assert_eq!(ok, FALSE);
4237    }
4238
4239    #[test]
4240    fn open_output_file_writes_and_attaches_journal() {
4241        let p = create_unconstrained();
4242        let dir = std::env::temp_dir().join("pounce-cinterface-test");
4243        let _ = std::fs::create_dir_all(&dir);
4244        let path = dir.join("output.log");
4245        let cstr = CString::new(path.to_string_lossy().as_bytes()).unwrap();
4246        let ok = unsafe { OpenIpoptOutputFile(p, cstr.as_ptr(), 5) };
4247        assert_eq!(ok, TRUE);
4248        // Option should be reflected in the app.
4249        let info = unsafe { &*p };
4250        let (level, found) = info
4251            .app
4252            .options()
4253            .get_integer_value("file_print_level", "")
4254            .unwrap();
4255        assert!(found);
4256        assert_eq!(level, 5);
4257        unsafe { FreeIpoptProblem(p) };
4258        let _ = std::fs::remove_file(&path);
4259    }
4260
4261    #[test]
4262    fn open_output_file_with_null_inputs_returns_false() {
4263        // error-path: OpenIpoptOutputFile
4264        let key = CString::new("nope").unwrap();
4265        unsafe {
4266            assert_eq!(
4267                OpenIpoptOutputFile(std::ptr::null_mut(), key.as_ptr(), 0),
4268                FALSE
4269            );
4270        }
4271        let p = create_unconstrained();
4272        unsafe {
4273            assert_eq!(OpenIpoptOutputFile(p, std::ptr::null(), 0), FALSE);
4274            FreeIpoptProblem(p);
4275        }
4276    }
4277
4278    #[test]
4279    fn get_current_iterate_returns_false_outside_callback() {
4280        // error-path: GetIpoptCurrentIterate
4281        let p = create_unconstrained();
4282        let rc = unsafe {
4283            GetIpoptCurrentIterate(
4284                p,
4285                FALSE,
4286                0,
4287                std::ptr::null_mut(),
4288                std::ptr::null_mut(),
4289                std::ptr::null_mut(),
4290                0,
4291                std::ptr::null_mut(),
4292                std::ptr::null_mut(),
4293            )
4294        };
4295        assert_eq!(rc, FALSE);
4296        unsafe { FreeIpoptProblem(p) };
4297    }
4298
4299    #[test]
4300    fn get_current_violations_returns_false_outside_callback() {
4301        // error-path: GetIpoptCurrentViolations
4302        let p = create_unconstrained();
4303        let rc = unsafe {
4304            GetIpoptCurrentViolations(
4305                p,
4306                FALSE,
4307                0,
4308                std::ptr::null_mut(),
4309                std::ptr::null_mut(),
4310                std::ptr::null_mut(),
4311                std::ptr::null_mut(),
4312                std::ptr::null_mut(),
4313                0,
4314                std::ptr::null_mut(),
4315                std::ptr::null_mut(),
4316            )
4317        };
4318        assert_eq!(rc, FALSE);
4319        unsafe { FreeIpoptProblem(p) };
4320    }
4321
4322    #[test]
4323    fn post_solve_stats_zero_before_solve() {
4324        // error-path: GetIpoptIterCount
4325        // error-path: GetIpoptSolveTime
4326        // error-path: GetIpoptPrimalInf
4327        // error-path: GetIpoptDualInf
4328        // error-path: GetIpoptComplInf
4329        let p = create_unconstrained();
4330        unsafe {
4331            assert_eq!(GetIpoptIterCount(p), 0);
4332            assert_eq!(GetIpoptSolveTime(p), 0.0);
4333            assert_eq!(GetIpoptPrimalInf(p), 0.0);
4334            assert_eq!(GetIpoptDualInf(p), 0.0);
4335            assert_eq!(GetIpoptComplInf(p), 0.0);
4336            FreeIpoptProblem(p);
4337        }
4338    }
4339
4340    #[test]
4341    fn post_solve_stats_populated_after_solve() {
4342        // Reuse the same quadratic as the end-to-end solve test.
4343        let xl = [-1.0e20];
4344        let xu = [1.0e20];
4345        let p = unsafe {
4346            CreateIpoptProblem(
4347                1,
4348                xl.as_ptr(),
4349                xu.as_ptr(),
4350                0,
4351                std::ptr::null(),
4352                std::ptr::null(),
4353                0,
4354                1,
4355                0,
4356                Some(quad_eval_f),
4357                None,
4358                Some(quad_eval_grad_f),
4359                None,
4360                Some(quad_eval_h),
4361            )
4362        };
4363        let mut x = [0.0_f64];
4364        let mut obj = 0.0_f64;
4365        let rc = unsafe {
4366            IpoptSolve(
4367                p,
4368                x.as_mut_ptr(),
4369                std::ptr::null_mut(),
4370                &mut obj,
4371                std::ptr::null_mut(),
4372                std::ptr::null_mut(),
4373                std::ptr::null_mut(),
4374                std::ptr::null_mut(),
4375            )
4376        };
4377        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4378        // After a successful solve, iter count is recorded (>= 0) and
4379        // wall time is non-negative; primal/dual/compl norms exist.
4380        unsafe {
4381            assert!(GetIpoptIterCount(p) >= 0);
4382            assert!(GetIpoptSolveTime(p) >= 0.0);
4383            assert!(GetIpoptPrimalInf(p).is_finite());
4384            assert!(GetIpoptDualInf(p).is_finite());
4385            assert!(GetIpoptComplInf(p).is_finite());
4386            FreeIpoptProblem(p);
4387        }
4388    }
4389
4390    /// The linear-solver post-mortem is reported for a real solve, and
4391    /// reports the backend that actually ran rather than the one the
4392    /// option asked for — which is the question a caller pinning
4393    /// `linear_solver` is really asking.
4394    #[test]
4395    fn linear_solver_stats_populated_after_solve() {
4396        // error-path: GetPounceLinearSolverStats
4397        let xl = [-1.0e20];
4398        let xu = [1.0e20];
4399        let p = unsafe {
4400            CreateIpoptProblem(
4401                1,
4402                xl.as_ptr(),
4403                xu.as_ptr(),
4404                0,
4405                std::ptr::null(),
4406                std::ptr::null(),
4407                0,
4408                1,
4409                0,
4410                Some(quad_eval_f),
4411                None,
4412                Some(quad_eval_grad_f),
4413                None,
4414                Some(quad_eval_h),
4415            )
4416        };
4417        let mut stats = unsafe { std::mem::zeroed::<PounceLinearSolverStats>() };
4418        // Nothing to report before the first solve.
4419        assert_eq!(unsafe { GetPounceLinearSolverStats(p, &mut stats) }, FALSE);
4420
4421        let mut x = [0.0_f64];
4422        let mut obj = 0.0_f64;
4423        let rc = unsafe {
4424            IpoptSolve(
4425                p,
4426                x.as_mut_ptr(),
4427                std::ptr::null_mut(),
4428                &mut obj,
4429                std::ptr::null_mut(),
4430                std::ptr::null_mut(),
4431                std::ptr::null_mut(),
4432                std::ptr::null_mut(),
4433            )
4434        };
4435        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4436        assert_eq!(unsafe { GetPounceLinearSolverStats(p, &mut stats) }, TRUE);
4437
4438        let name = unsafe { CStr::from_ptr(stats.solver_name.as_ptr()) }
4439            .to_str()
4440            .expect("solver name is ASCII");
4441        assert_eq!(name, "feral", "default backend should report itself");
4442        assert!(stats.n_factors > 0, "n_factors = {}", stats.n_factors);
4443        assert_eq!(
4444            stats.n_pattern_reuse + stats.n_pattern_changes,
4445            stats.n_factors,
4446            "every factor is either a pattern reuse or a pattern change"
4447        );
4448        // Optional fields are either a real value or the documented
4449        // sentinel — never a silent zero.
4450        assert!(stats.max_fill_ratio.is_nan() || stats.max_fill_ratio > 0.0);
4451        assert!(stats.last_nnz_l == -1 || stats.last_nnz_l > 0);
4452        unsafe { FreeIpoptProblem(p) };
4453    }
4454
4455    #[test]
4456    fn option_type_reports_the_setter_a_keyword_expects() {
4457        // error-path: GetPounceOptionType
4458        let p = create_unconstrained();
4459        let ty = |s: &str| {
4460            let c = std::ffi::CString::new(s).unwrap();
4461            unsafe { GetPounceOptionType(p, c.as_ptr()) }
4462        };
4463        assert_eq!(ty("tol"), 1, "tol is a number");
4464        assert_eq!(ty("max_iter"), 2, "max_iter is an integer");
4465        assert_eq!(ty("linear_solver"), 3, "linear_solver is a string");
4466        // `hessian_approximation` is the option the CasADi plugin has to
4467        // set as a string while the user may well type it as one too.
4468        assert_eq!(ty("hessian_approximation"), 3);
4469        // Unregistered, and NULL keyword, both answer "unknown" rather
4470        // than guessing a type.
4471        assert_eq!(ty("no_such_option_at_all"), 0);
4472        assert_eq!(unsafe { GetPounceOptionType(p, std::ptr::null()) }, 0);
4473        unsafe { FreeIpoptProblem(p) };
4474    }
4475
4476    /// A NULL problem handle answers from the same registry — the case a
4477    /// code generator is in, having no problem to ask.
4478    #[test]
4479    fn option_type_answers_without_a_problem_handle() {
4480        let ty = |s: &str| {
4481            let c = std::ffi::CString::new(s).unwrap();
4482            unsafe { GetPounceOptionType(std::ptr::null_mut(), c.as_ptr()) }
4483        };
4484        assert_eq!(ty("tol"), 1);
4485        assert_eq!(ty("max_iter"), 2);
4486        assert_eq!(ty("linear_solver"), 3);
4487        assert_eq!(ty("no_such_option_at_all"), 0);
4488
4489        // …and the same answers a live problem gives, which is the
4490        // property that keeps the two paths from drifting apart.
4491        let p = create_unconstrained();
4492        for name in [
4493            "tol",
4494            "max_iter",
4495            "linear_solver",
4496            "mu_strategy",
4497            "print_level",
4498        ] {
4499            let c = std::ffi::CString::new(name).unwrap();
4500            assert_eq!(
4501                unsafe { GetPounceOptionType(std::ptr::null_mut(), c.as_ptr()) },
4502                unsafe { GetPounceOptionType(p, c.as_ptr()) },
4503                "handle-free and problem-bound disagree on {name}"
4504            );
4505        }
4506        unsafe { FreeIpoptProblem(p) };
4507    }
4508
4509    #[test]
4510    fn write_solve_report_emits_v1_json_with_iter_history() {
4511        // Quadratic — Newton driver, single iter; just exercises the
4512        // post-solve report path end-to-end.
4513        let xl = [-1.0e20];
4514        let xu = [1.0e20];
4515        let p = unsafe {
4516            CreateIpoptProblem(
4517                1,
4518                xl.as_ptr(),
4519                xu.as_ptr(),
4520                0,
4521                std::ptr::null(),
4522                std::ptr::null(),
4523                0,
4524                1,
4525                0,
4526                Some(quad_eval_f),
4527                None,
4528                Some(quad_eval_grad_f),
4529                None,
4530                Some(quad_eval_h),
4531            )
4532        };
4533
4534        // Write before any solve must fail.
4535        let cpath = CString::new("/tmp/pounce_cinterface_no_solve.json").unwrap();
4536        let bad = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), std::ptr::null()) };
4537        assert_eq!(bad, FALSE);
4538
4539        // Enable per-iter capture, solve, then write at detail = full.
4540        assert_eq!(unsafe { IpoptEnableIterHistory(p) }, TRUE);
4541        let mut x = [0.0_f64];
4542        let mut obj = 0.0_f64;
4543        let rc = unsafe {
4544            IpoptSolve(
4545                p,
4546                x.as_mut_ptr(),
4547                std::ptr::null_mut(),
4548                &mut obj,
4549                std::ptr::null_mut(),
4550                std::ptr::null_mut(),
4551                std::ptr::null_mut(),
4552                std::ptr::null_mut(),
4553            )
4554        };
4555        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4556
4557        let dir = std::env::temp_dir();
4558        let path = dir.join("pounce_cinterface_report.json");
4559        let cpath = CString::new(path.to_str().unwrap()).unwrap();
4560        let cdetail = CString::new("full").unwrap();
4561        let ok = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), cdetail.as_ptr()) };
4562        assert_eq!(ok, TRUE);
4563
4564        // Read it back and check the schema tag + that it parses with
4565        // the same struct shape pounce-cli uses.
4566        let txt = std::fs::read_to_string(&path).unwrap();
4567        assert!(
4568            txt.contains("\"schema\": \"pounce.solve-report/v1\""),
4569            "{txt}"
4570        );
4571        assert!(txt.contains("\"kind\": \"tnlp-direct\""));
4572        let parsed: pounce_solve_report::SolveReport = serde_json::from_str(&txt).unwrap();
4573        assert_eq!(parsed.problem.n_variables, 1);
4574        assert_eq!(parsed.problem.n_constraints, 0);
4575
4576        // Invalid detail string is rejected.
4577        let bad_detail = CString::new("verbose").unwrap();
4578        let bad = unsafe { IpoptWriteSolveReport(p, cpath.as_ptr(), bad_detail.as_ptr()) };
4579        assert_eq!(bad, FALSE);
4580
4581        let _ = std::fs::remove_file(&path);
4582        unsafe { FreeIpoptProblem(p) };
4583    }
4584
4585    // --- Intermediate-callback wiring (issue #19, follow-up) ---
4586    //
4587    // The callback only fires on the IPM path (`optimize_constrained`).
4588    // Unconstrained problems short-circuit through the Newton driver,
4589    // so these tests use a single-inequality problem to force the IPM.
4590
4591    unsafe extern "C" fn cb_quad_eval_g(
4592        _n: Index,
4593        x: *const Number,
4594        _new_x: Bool,
4595        _m: Index,
4596        g: *mut Number,
4597        _user_data: *mut c_void,
4598    ) -> Bool {
4599        unsafe {
4600            *g.offset(0) = *x.offset(0);
4601            TRUE
4602        }
4603    }
4604    unsafe extern "C" fn cb_quad_eval_jac_g(
4605        _n: Index,
4606        _x: *const Number,
4607        _new_x: Bool,
4608        _m: Index,
4609        nele_jac: Index,
4610        irow: *mut Index,
4611        jcol: *mut Index,
4612        values: *mut Number,
4613        _user_data: *mut c_void,
4614    ) -> Bool {
4615        unsafe {
4616            assert_eq!(nele_jac, 1);
4617            if !irow.is_null() {
4618                *irow.offset(0) = 0;
4619                *jcol.offset(0) = 0;
4620            }
4621            if !values.is_null() {
4622                *values.offset(0) = 1.0;
4623            }
4624            TRUE
4625        }
4626    }
4627    unsafe extern "C" fn cb_quad_eval_h(
4628        _n: Index,
4629        _x: *const Number,
4630        _new_x: Bool,
4631        obj_factor: Number,
4632        _m: Index,
4633        _lambda: *const Number,
4634        _new_lambda: Bool,
4635        _nele_hess: Index,
4636        irow: *mut Index,
4637        jcol: *mut Index,
4638        values: *mut Number,
4639        _user_data: *mut c_void,
4640    ) -> Bool {
4641        unsafe {
4642            if !irow.is_null() {
4643                *irow.offset(0) = 0;
4644                *jcol.offset(0) = 0;
4645            }
4646            if !values.is_null() {
4647                *values.offset(0) = 2.0 * obj_factor;
4648            }
4649            TRUE
4650        }
4651    }
4652
4653    fn create_callback_test_problem() -> IpoptProblem {
4654        // min (x - 2)^2  s.t.  -10 <= x <= 10 (single inequality).
4655        let xl = [-1.0e20];
4656        let xu = [1.0e20];
4657        let gl = [-10.0];
4658        let gu = [10.0];
4659        unsafe {
4660            CreateIpoptProblem(
4661                1,
4662                xl.as_ptr(),
4663                xu.as_ptr(),
4664                1,
4665                gl.as_ptr(),
4666                gu.as_ptr(),
4667                1,
4668                1,
4669                0,
4670                Some(quad_eval_f),
4671                Some(cb_quad_eval_g),
4672                Some(quad_eval_grad_f),
4673                Some(cb_quad_eval_jac_g),
4674                Some(cb_quad_eval_h),
4675            )
4676        }
4677    }
4678
4679    static CB_ITER_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
4680    static CB_LAST_ITER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
4681    static CB_INSPECTOR_OK: std::sync::atomic::AtomicBool =
4682        std::sync::atomic::AtomicBool::new(false);
4683
4684    unsafe extern "C" fn counting_cb(
4685        _alg_mod: Index,
4686        iter_count: Index,
4687        _obj_value: Number,
4688        _inf_pr: Number,
4689        _inf_du: Number,
4690        _mu: Number,
4691        _d_norm: Number,
4692        _regularization_size: Number,
4693        _alpha_du: Number,
4694        _alpha_pr: Number,
4695        _ls_trials: Index,
4696        user_data: *mut c_void,
4697    ) -> Bool {
4698        unsafe {
4699            CB_ITER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4700            CB_LAST_ITER.store(iter_count, std::sync::atomic::Ordering::SeqCst);
4701            // user_data carries the IpoptProblem so we can exercise the
4702            // inspector from inside the callback.
4703            let problem = user_data as IpoptProblem;
4704            let mut x = [0.0_f64];
4705            let rc = GetIpoptCurrentIterate(
4706                problem,
4707                FALSE,
4708                1,
4709                x.as_mut_ptr(),
4710                std::ptr::null_mut(),
4711                std::ptr::null_mut(),
4712                1,
4713                std::ptr::null_mut(),
4714                std::ptr::null_mut(),
4715            );
4716            if rc == TRUE && x[0].is_finite() {
4717                CB_INSPECTOR_OK.store(true, std::sync::atomic::Ordering::SeqCst);
4718            }
4719            TRUE
4720        }
4721    }
4722
4723    #[test]
4724    fn intermediate_callback_fires_per_iteration_and_inspector_reads_x() {
4725        CB_ITER_COUNTER.store(0, std::sync::atomic::Ordering::SeqCst);
4726        CB_LAST_ITER.store(-1, std::sync::atomic::Ordering::SeqCst);
4727        CB_INSPECTOR_OK.store(false, std::sync::atomic::Ordering::SeqCst);
4728
4729        let p = create_callback_test_problem();
4730        assert!(!p.is_null());
4731        let ok = unsafe { SetIntermediateCallback(p, Some(counting_cb)) };
4732        assert_eq!(ok, TRUE);
4733        let mut x = [0.0_f64];
4734        let mut obj = 0.0_f64;
4735        let rc = unsafe {
4736            IpoptSolve(
4737                p,
4738                x.as_mut_ptr(),
4739                std::ptr::null_mut(),
4740                &mut obj,
4741                std::ptr::null_mut(),
4742                std::ptr::null_mut(),
4743                std::ptr::null_mut(),
4744                p as *mut c_void,
4745            )
4746        };
4747        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4748        // At least the iter-0 fire happened, plus one per accepted step.
4749        let n_fires = CB_ITER_COUNTER.load(std::sync::atomic::Ordering::SeqCst);
4750        assert!(n_fires >= 2, "callback fired {n_fires} times, want >=2");
4751        assert!(
4752            CB_LAST_ITER.load(std::sync::atomic::Ordering::SeqCst) >= 1,
4753            "last iter should be >= 1 after at least one accepted step"
4754        );
4755        assert!(
4756            CB_INSPECTOR_OK.load(std::sync::atomic::Ordering::SeqCst),
4757            "GetIpoptCurrentIterate did not return a usable x"
4758        );
4759        unsafe { FreeIpoptProblem(p) };
4760    }
4761
4762    static CB_VIOL_OK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
4763
4764    // Bounded variant of `create_callback_test_problem`: x in [0, 10] with a
4765    // finite lower bound, so the `x_l_violation` / `x_u_violation` branches of
4766    // GetIpoptCurrentViolations actually scatter a real `x_L`/`x_U` mapping
4767    // (not the degenerate "no bound" pack).
4768    fn create_bounded_callback_test_problem() -> IpoptProblem {
4769        // min (x - 2)^2  s.t.  -10 <= x <= 10,  x in [0, 10].
4770        let xl = [0.0];
4771        let xu = [10.0];
4772        let gl = [-10.0];
4773        let gu = [10.0];
4774        unsafe {
4775            CreateIpoptProblem(
4776                1,
4777                xl.as_ptr(),
4778                xu.as_ptr(),
4779                1,
4780                gl.as_ptr(),
4781                gu.as_ptr(),
4782                1,
4783                1,
4784                0,
4785                Some(quad_eval_f),
4786                Some(cb_quad_eval_g),
4787                Some(quad_eval_grad_f),
4788                Some(cb_quad_eval_jac_g),
4789                Some(cb_quad_eval_h),
4790            )
4791        }
4792    }
4793
4794    unsafe extern "C" fn violations_inspecting_cb(
4795        _alg_mod: Index,
4796        _iter_count: Index,
4797        _obj_value: Number,
4798        _inf_pr: Number,
4799        _inf_du: Number,
4800        _mu: Number,
4801        _d_norm: Number,
4802        _regularization_size: Number,
4803        _alpha_du: Number,
4804        _alpha_pr: Number,
4805        _ls_trials: Index,
4806        user_data: *mut c_void,
4807    ) -> Bool {
4808        unsafe {
4809            let problem = user_data as IpoptProblem;
4810            // Exercise the bound-violation branches (n=1, m=1) from inside an
4811            // installed intermediate context. Pre-L51 these branches indexed
4812            // `v[i]` without a length guard; the fix makes them return FALSE on
4813            // a packed-length mismatch instead of panicking across `extern "C"`.
4814            let mut x_l_viol = [f64::NAN];
4815            let mut x_u_viol = [f64::NAN];
4816            let rc = GetIpoptCurrentViolations(
4817                problem,
4818                FALSE,
4819                1,
4820                x_l_viol.as_mut_ptr(),
4821                x_u_viol.as_mut_ptr(),
4822                std::ptr::null_mut(),
4823                std::ptr::null_mut(),
4824                std::ptr::null_mut(),
4825                1,
4826                std::ptr::null_mut(),
4827                std::ptr::null_mut(),
4828            );
4829            if rc == TRUE
4830                && x_l_viol[0].is_finite()
4831                && x_l_viol[0] >= 0.0
4832                && x_u_viol[0].is_finite()
4833                && x_u_viol[0] >= 0.0
4834            {
4835                CB_VIOL_OK.store(true, std::sync::atomic::Ordering::SeqCst);
4836            }
4837            TRUE
4838        }
4839    }
4840
4841    #[test]
4842    fn get_current_violations_inside_callback_reports_finite_bounds() {
4843        CB_VIOL_OK.store(false, std::sync::atomic::Ordering::SeqCst);
4844        let p = create_bounded_callback_test_problem();
4845        assert!(!p.is_null());
4846        let ok = unsafe { SetIntermediateCallback(p, Some(violations_inspecting_cb)) };
4847        assert_eq!(ok, TRUE);
4848        let mut x = [5.0_f64];
4849        let mut obj = 0.0_f64;
4850        let rc = unsafe {
4851            IpoptSolve(
4852                p,
4853                x.as_mut_ptr(),
4854                std::ptr::null_mut(),
4855                &mut obj,
4856                std::ptr::null_mut(),
4857                std::ptr::null_mut(),
4858                std::ptr::null_mut(),
4859                p as *mut c_void,
4860            )
4861        };
4862        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
4863        assert!(
4864            CB_VIOL_OK.load(std::sync::atomic::Ordering::SeqCst),
4865            "GetIpoptCurrentViolations did not return finite, non-negative \
4866             bound violations from inside the callback"
4867        );
4868        unsafe { FreeIpoptProblem(p) };
4869    }
4870
4871    #[test]
4872    fn bound_violation_scatter_rejects_oversized_pack_instead_of_panicking() {
4873        // L51 fail-first (logic level): reproduce the scatter of the
4874        // `x_l_violation` / `x_u_violation` branches. The packed vector comes
4875        // from `pack_z_*_for_user`, whose length must equal the output `n`.
4876        // Pre-fix the branches scattered it with `for (i, s) in
4877        // packed.enumerate() { v[i] = ... }` over a `vec![0.0; n]` *without*
4878        // checking the length; an oversized pack indexes `v[i]` out of bounds
4879        // and panics — and across the real `extern "C"` boundary that panic
4880        // aborts the embedding process. The fix adds the same length guard
4881        // the sibling (`compl_*`, `grad_lag_x`) branches already had.
4882        let n_us = 1usize;
4883        let packed = [0.5_f64, -0.3]; // len 2 != n_us == 1
4884
4885        // Pre-fix: the unguarded scatter panics on the oversized pack.
4886        let unguarded = std::panic::catch_unwind(|| {
4887            let mut v = vec![0.0; n_us];
4888            for (i, s) in packed.iter().enumerate() {
4889                v[i] = (-s).max(0.0);
4890            }
4891            v
4892        });
4893        assert!(
4894            unguarded.is_err(),
4895            "unguarded scatter should panic (→ abort across extern \"C\") on an oversized pack"
4896        );
4897
4898        // Post-fix: the length guard returns an error instead of panicking.
4899        let guarded: Result<Vec<f64>, ()> = (|| {
4900            if packed.len() != n_us {
4901                return Err(());
4902            }
4903            let mut v = vec![0.0; n_us];
4904            for (i, s) in packed.iter().enumerate() {
4905                v[i] = (-s).max(0.0);
4906            }
4907            Ok(v)
4908        })();
4909        assert!(
4910            guarded.is_err(),
4911            "guarded scatter should reject the length mismatch (return FALSE), not panic"
4912        );
4913    }
4914
4915    unsafe extern "C" fn user_stop_cb(
4916        _alg_mod: Index,
4917        _iter_count: Index,
4918        _obj_value: Number,
4919        _inf_pr: Number,
4920        _inf_du: Number,
4921        _mu: Number,
4922        _d_norm: Number,
4923        _regularization_size: Number,
4924        _alpha_du: Number,
4925        _alpha_pr: Number,
4926        _ls_trials: Index,
4927        _user_data: *mut c_void,
4928    ) -> Bool {
4929        FALSE
4930    }
4931
4932    #[test]
4933    fn intermediate_callback_false_surfaces_user_requested_stop() {
4934        let p = create_callback_test_problem();
4935        assert!(!p.is_null());
4936        let ok = unsafe { SetIntermediateCallback(p, Some(user_stop_cb)) };
4937        assert_eq!(ok, TRUE);
4938        let mut x = [0.0_f64];
4939        let rc = unsafe {
4940            IpoptSolve(
4941                p,
4942                x.as_mut_ptr(),
4943                std::ptr::null_mut(),
4944                std::ptr::null_mut(),
4945                std::ptr::null_mut(),
4946                std::ptr::null_mut(),
4947                std::ptr::null_mut(),
4948                std::ptr::null_mut(),
4949            )
4950        };
4951        assert_eq!(rc, ApplicationReturnStatus::UserRequestedStop as Index);
4952        unsafe { FreeIpoptProblem(p) };
4953    }
4954
4955    #[test]
4956    fn ffi_guard_converts_panic_to_fallback() {
4957        // L56: a panic in pounce's own Rust code during a solve must be
4958        // caught at the FFI boundary and reported as `Internal_Error`, never
4959        // unwound across `extern "C"` (which aborts the embedding process).
4960        // This exercises the exact mechanism wrapping IpoptSolve /
4961        // IpoptSolveWarmStart. (The "boom" panic message printing to stderr
4962        // is expected — the default panic hook still runs before the catch.)
4963        let fallback = ApplicationReturnStatus::InternalError as Index;
4964        let got = ffi_guard(fallback, || -> Index {
4965            panic!("boom inside solver core");
4966        });
4967        assert_eq!(got, fallback);
4968        assert_eq!(got, ApplicationReturnStatus::InternalError as Index);
4969    }
4970
4971    #[test]
4972    fn ffi_guard_is_transparent_on_success() {
4973        // On the happy path the guard returns the body's value unchanged, so
4974        // wrapping IpoptSolve does not alter normal solves (the end-to-end
4975        // solve tests above confirm this at the public-API level).
4976        let got = ffi_guard(-99, || 7);
4977        assert_eq!(got, 7);
4978    }
4979
4980    #[test]
4981    fn parse_pkg_version_handles_missing_components() {
4982        assert_eq!(parse_pkg_version("1.2.3"), (1, 2, 3));
4983        assert_eq!(parse_pkg_version("4.5"), (4, 5, 0));
4984        assert_eq!(parse_pkg_version(""), (0, 0, 0));
4985        assert_eq!(parse_pkg_version("1.x.3"), (1, 0, 3));
4986    }
4987
4988    // ---- Solver-session C ABI (crate::solver) ----
4989
4990    use crate::solver::{
4991        IpoptCreateSolver, IpoptFreeSolver, IpoptSolverGetKktDim, IpoptSolverKktSolve,
4992        IpoptSolverSolve,
4993    };
4994
4995    #[test]
4996    fn solver_create_consumes_problem_handle() {
4997        let mut p = create_unconstrained();
4998        assert!(!p.is_null());
4999        let s = unsafe { IpoptCreateSolver(&mut p) };
5000        assert!(!s.is_null());
5001        assert!(
5002            p.is_null(),
5003            "IpoptCreateSolver should NULL out the caller's handle"
5004        );
5005        unsafe { IpoptFreeSolver(s) };
5006    }
5007
5008    #[test]
5009    fn solver_create_null_inputs_return_null() {
5010        // error-path: IpoptCreateSolver
5011        // NULL pointer-to-handle.
5012        let s = unsafe { IpoptCreateSolver(std::ptr::null_mut()) };
5013        assert!(s.is_null());
5014        // Pointer to a NULL handle.
5015        let mut p: IpoptProblem = std::ptr::null_mut();
5016        let s = unsafe { IpoptCreateSolver(&mut p) };
5017        assert!(s.is_null());
5018    }
5019
5020    #[test]
5021    fn solver_free_null_is_safe() {
5022        // error-path: IpoptFreeSolver
5023        unsafe { IpoptFreeSolver(std::ptr::null_mut()) };
5024    }
5025
5026    #[test]
5027    fn solver_solve_drives_quadratic_and_retains_factor() {
5028        let xl = [-1.0e20];
5029        let xu = [1.0e20];
5030        let mut p = unsafe {
5031            CreateIpoptProblem(
5032                1,
5033                xl.as_ptr(),
5034                xu.as_ptr(),
5035                0,
5036                std::ptr::null(),
5037                std::ptr::null(),
5038                0,
5039                1,
5040                0,
5041                Some(quad_eval_f),
5042                None,
5043                Some(quad_eval_grad_f),
5044                None,
5045                Some(quad_eval_h),
5046            )
5047        };
5048        assert!(!p.is_null());
5049        let s = unsafe { IpoptCreateSolver(&mut p) };
5050        assert!(!s.is_null());
5051        let mut x = [0.0_f64];
5052        let mut obj = 0.0_f64;
5053        let rc = unsafe {
5054            IpoptSolverSolve(
5055                s,
5056                x.as_mut_ptr(),
5057                std::ptr::null_mut(),
5058                &mut obj,
5059                std::ptr::null_mut(),
5060                std::ptr::null_mut(),
5061                std::ptr::null_mut(),
5062                std::ptr::null_mut(),
5063            )
5064        };
5065        assert_eq!(rc, ApplicationReturnStatus::SolveSucceeded as Index);
5066        assert!((x[0] - 2.0).abs() < 1e-6);
5067        assert!(obj.abs() < 1e-10);
5068
5069        // After convergence the factor is retained — kkt_dim is positive
5070        // and a zero RHS back-solves to zero.
5071        let dim = unsafe { IpoptSolverGetKktDim(s) };
5072        assert!(dim > 0, "expected positive KKT dim, got {dim}");
5073        let rhs = vec![0.0_f64; dim as usize];
5074        let mut lhs = vec![1.0_f64; dim as usize];
5075        let ok = unsafe { IpoptSolverKktSolve(s, rhs.as_ptr(), lhs.as_mut_ptr()) };
5076        assert_eq!(ok, TRUE);
5077        for (i, v) in lhs.iter().enumerate() {
5078            assert!(v.abs() < 1e-10, "lhs[{i}] = {v} not ~0");
5079        }
5080        unsafe { IpoptFreeSolver(s) };
5081    }
5082
5083    #[test]
5084    fn solver_kkt_dim_minus_one_before_solve() {
5085        // error-path: IpoptSolverGetKktDim
5086        let mut p = create_unconstrained();
5087        let s = unsafe { IpoptCreateSolver(&mut p) };
5088        assert_eq!(unsafe { IpoptSolverGetKktDim(s) }, -1);
5089        unsafe { IpoptFreeSolver(s) };
5090    }
5091
5092    // ─────────────────────────────────────────────────────────
5093    // §7.2 SQP working-set warm-start C ABI tests.
5094    // ─────────────────────────────────────────────────────────
5095
5096    #[test]
5097    fn c_get_working_set_returns_false_before_any_solve() {
5098        // error-path: IpoptGetWorkingSet
5099        let p = create_unconstrained();
5100        let mut bound_buf = [0; 4];
5101        let rc = unsafe { IpoptGetWorkingSet(p, bound_buf.as_mut_ptr(), std::ptr::null_mut()) };
5102        assert_eq!(rc, FALSE);
5103        unsafe { FreeIpoptProblem(p) };
5104    }
5105
5106    #[test]
5107    fn c_set_warm_start_with_both_null_returns_false() {
5108        let p = create_unconstrained();
5109        let rc = unsafe { IpoptSetWarmStartWorkingSet(p, std::ptr::null(), std::ptr::null()) };
5110        assert_eq!(rc, FALSE);
5111        unsafe { FreeIpoptProblem(p) };
5112    }
5113
5114    #[test]
5115    fn c_set_warm_start_with_bad_status_code_returns_false() {
5116        // error-path: IpoptSetWarmStartWorkingSet
5117        let p = create_unconstrained();
5118        // Length n = 4; '7' is out of range (valid: 0..=3).
5119        let bogus = [
5120            POUNCE_WS_INACTIVE,
5121            7,
5122            POUNCE_WS_AT_LOWER,
5123            POUNCE_WS_INACTIVE,
5124        ];
5125        let rc = unsafe { IpoptSetWarmStartWorkingSet(p, bogus.as_ptr(), std::ptr::null()) };
5126        assert_eq!(rc, FALSE);
5127        unsafe { FreeIpoptProblem(p) };
5128    }
5129
5130    #[test]
5131    fn c_set_warm_start_then_clear_succeeds() {
5132        let p = create_unconstrained();
5133        let in_buf = [POUNCE_WS_INACTIVE; 4];
5134        let set_rc = unsafe { IpoptSetWarmStartWorkingSet(p, in_buf.as_ptr(), std::ptr::null()) };
5135        assert_eq!(set_rc, TRUE);
5136        let clr_rc = unsafe { IpoptClearWarmStartWorkingSet(p) };
5137        assert_eq!(clr_rc, TRUE);
5138        unsafe { FreeIpoptProblem(p) };
5139    }
5140
5141    #[test]
5142    fn c_set_warm_start_on_null_problem_returns_false() {
5143        let in_buf = [POUNCE_WS_INACTIVE; 1];
5144        let rc = unsafe {
5145            IpoptSetWarmStartWorkingSet(std::ptr::null_mut(), in_buf.as_ptr(), std::ptr::null())
5146        };
5147        assert_eq!(rc, FALSE);
5148    }
5149
5150    #[test]
5151    fn c_solve_warm_start_round_trips_working_set_on_sqp_path() {
5152        // Use the 1-D `(x − 2)²` quadratic from
5153        // `create_callback_test_problem`. Set `algorithm
5154        // active-set-sqp`, solve, then read the working set
5155        // through `IpoptGetWorkingSet`. Pass it back via
5156        // `IpoptSolveWarmStart` for a second solve.
5157        let p = create_callback_test_problem();
5158        let key = CString::new("algorithm").unwrap();
5159        let val = CString::new("active-set-sqp").unwrap();
5160        let ok = unsafe { AddIpoptStrOption(p, key.as_ptr(), val.as_ptr()) };
5161        assert_eq!(ok, TRUE);
5162
5163        let mut x = [0.0_f64];
5164        let mut obj = 0.0_f64;
5165        let rc1 = unsafe {
5166            IpoptSolve(
5167                p,
5168                x.as_mut_ptr(),
5169                std::ptr::null_mut(),
5170                &mut obj,
5171                std::ptr::null_mut(),
5172                std::ptr::null_mut(),
5173                std::ptr::null_mut(),
5174                std::ptr::null_mut(),
5175            )
5176        };
5177        assert_eq!(rc1, ApplicationReturnStatus::SolveSucceeded as Index);
5178
5179        let mut bound_buf = [-1; 1];
5180        let mut cons_buf = [-1; 1];
5181        let got = unsafe { IpoptGetWorkingSet(p, bound_buf.as_mut_ptr(), cons_buf.as_mut_ptr()) };
5182        assert_eq!(got, TRUE);
5183        // Status codes must be in 0..=3.
5184        assert!((0..=3).contains(&bound_buf[0]));
5185        assert!((0..=3).contains(&cons_buf[0]));
5186
5187        // Second solve with the just-retrieved working set as
5188        // input. Resets x to a non-optimal starting point so the
5189        // SQP loop actually has work to do; the warm-start
5190        // should still converge to the optimum.
5191        x[0] = 0.0;
5192        let mut obj2 = 0.0_f64;
5193        let mut bound_out = [-1; 1];
5194        let mut cons_out = [-1; 1];
5195        let rc2 = unsafe {
5196            IpoptSolveWarmStart(
5197                p,
5198                x.as_mut_ptr(),
5199                std::ptr::null_mut(),
5200                &mut obj2,
5201                std::ptr::null_mut(),
5202                std::ptr::null_mut(),
5203                std::ptr::null_mut(),
5204                bound_buf.as_ptr(),
5205                cons_buf.as_ptr(),
5206                bound_out.as_mut_ptr(),
5207                cons_out.as_mut_ptr(),
5208                std::ptr::null_mut(),
5209            )
5210        };
5211        assert_eq!(rc2, ApplicationReturnStatus::SolveSucceeded as Index);
5212        assert!((0..=3).contains(&bound_out[0]));
5213        assert!((0..=3).contains(&cons_out[0]));
5214
5215        unsafe { FreeIpoptProblem(p) };
5216    }
5217
5218    // ---- complementarity outcome encodings (gh#1147) ----
5219    //
5220    // The integration tests (`tests/complementarity_getters.rs`) reach the
5221    // S, W, none and not-searched classes, the answer / weak-fallback /
5222    // verdict reports, the B verdict and a taken polish through real
5223    // solves. What no in-repo model reaches is held here, on a synthetic
5224    // driver record: no certificate, a feasible-uncertified report, the
5225    // not_B and not_checked verdicts, a polish that was not taken, and
5226    // non-finite reals.
5227
5228    fn synthetic_outcome() -> pounce_restoration::mpcc_driver::MpccOutcome {
5229        use pounce_algorithm::mpcc::{
5230            Certificate, MpccStage, Reported, StationarityClass, Strategy,
5231        };
5232        use pounce_restoration::mpcc_driver::{MpccOutcome, MpccSolveRecord};
5233        MpccOutcome {
5234            status: ApplicationReturnStatus::SolveSucceeded,
5235            statistics: SolveStatistics::default(),
5236            accepted_by: Some(MpccStage::Exact),
5237            fallback: None,
5238            reported: Reported::Answer(MpccStage::Exact),
5239            strategy: Strategy::Continuation,
5240            solves: vec![MpccSolveRecord {
5241                stage: MpccStage::Branch,
5242                tau: None,
5243                status: ApplicationReturnStatus::MaximumIterationsExceeded,
5244                iterations: 7,
5245                budget_iterations: 7,
5246                max_abs_product: Number::INFINITY,
5247                seconds: Number::NAN,
5248            }],
5249            total_iterations: 7,
5250            x: vec![0.0],
5251            max_abs_product: Number::INFINITY,
5252            certificate: Some(Certificate {
5253                class: StationarityClass::S,
5254                n_pairs: 1,
5255                n_biactive: 0,
5256                n_neither: 0,
5257                multipliers_unique: true,
5258                residual: 0.0,
5259                resid_tol: Number::INFINITY,
5260                tight: true,
5261                primal_violation: 0.0,
5262                feasible: true,
5263                worst_biactive_sign: 0.0,
5264                multipliers: None,
5265            }),
5266            b_check: None,
5267            escapes: 3,
5268            lifted: 2,
5269            natural_residual: None,
5270            polish: None,
5271            polish_threshold: 1e-7,
5272        }
5273    }
5274
5275    fn c_label(chars: &[c_char]) -> String {
5276        unsafe { CStr::from_ptr(chars.as_ptr()) }
5277            .to_str()
5278            .unwrap()
5279            .to_string()
5280    }
5281
5282    #[test]
5283    fn complementarity_encodings_absent_certificate_and_non_finite_reals() {
5284        let mut out = synthetic_outcome();
5285        out.certificate = None;
5286        let s = complementarity_stats(&out);
5287        assert_eq!(s.certificate_class, POUNCE_CC_CLASS_ABSENT);
5288        assert_eq!(
5289            (
5290                s.n_pairs,
5291                s.n_biactive,
5292                s.n_neither,
5293                s.multipliers_unique,
5294                s.tight,
5295                s.feasible
5296            ),
5297            (-1, -1, -1, -1, -1, -1)
5298        );
5299        assert!(s.residual.is_nan() && s.resid_tol.is_nan() && s.primal_violation.is_nan());
5300        // Non-finite reals read NaN (the report's `null`), an absent
5301        // natural residual too.
5302        assert!(s.max_abs_product.is_nan() && s.natural_residual.is_nan());
5303        assert_eq!(c_label(&s.strategy), "continuation");
5304        assert_eq!(c_label(&s.accepted_by), "exact product");
5305        assert_eq!(c_label(&s.fallback), "");
5306        assert_eq!((s.escapes, s.lifted), (3, 2));
5307        // The summary still exists without a certificate.
5308        assert!(complementarity_text(&out, POUNCE_CC_TEXT_SUMMARY).is_some());
5309
5310        // With a certificate, a non-finite band reads NaN, the rest as is.
5311        let s = complementarity_stats(&synthetic_outcome());
5312        assert_eq!(s.certificate_class, POUNCE_CC_CLASS_S);
5313        assert!(s.resid_tol.is_nan());
5314        assert_eq!(
5315            (s.n_pairs, s.multipliers_unique, s.tight, s.feasible),
5316            (1, 1, 1, 1)
5317        );
5318
5319        let r = complementarity_solve(&out.solves[0]);
5320        assert_eq!(c_label(&r.stage), "branch NLP");
5321        assert_eq!((r.relaxed, r.status, r.iterations), (0, -1, 7));
5322        assert!(r.tau.is_nan() && r.max_abs_product.is_nan() && r.seconds.is_nan());
5323    }
5324
5325    #[test]
5326    fn complementarity_encodings_every_reported_and_class() {
5327        use pounce_algorithm::mpcc::{MpccStage, Reported, StationarityClass};
5328        let mut out = synthetic_outcome();
5329        for (reported, code) in [
5330            (
5331                Reported::Answer(MpccStage::Exact),
5332                POUNCE_CC_REPORTED_ANSWER,
5333            ),
5334            (
5335                Reported::WeakFallback(MpccStage::Penalty),
5336                POUNCE_CC_REPORTED_WEAK_FALLBACK,
5337            ),
5338            (
5339                Reported::FeasibleUncertified(MpccStage::Continuation),
5340                POUNCE_CC_REPORTED_FEASIBLE_UNCERTIFIED,
5341            ),
5342            (Reported::Verdict, POUNCE_CC_REPORTED_VERDICT),
5343        ] {
5344            out.reported = reported;
5345            assert_eq!(complementarity_stats(&out).reported, code, "{reported:?}");
5346        }
5347        for (class, code) in [
5348            (StationarityClass::S, POUNCE_CC_CLASS_S),
5349            (StationarityClass::W, POUNCE_CC_CLASS_W),
5350            (StationarityClass::None, POUNCE_CC_CLASS_NONE),
5351            (StationarityClass::NotSearched, POUNCE_CC_CLASS_NOT_SEARCHED),
5352        ] {
5353            out.certificate.as_mut().unwrap().class = class;
5354            assert_eq!(
5355                complementarity_stats(&out).certificate_class,
5356                code,
5357                "{class:?}"
5358            );
5359        }
5360    }
5361
5362    #[test]
5363    fn complementarity_encodings_b_verdicts_and_b_reason() {
5364        use pounce_algorithm::mpcc::{BCheck, BVerdict};
5365        let mut out = synthetic_outcome();
5366        assert_eq!(
5367            complementarity_stats(&out).b_verdict,
5368            POUNCE_CC_B_VERDICT_ABSENT
5369        );
5370        assert_eq!(complementarity_text(&out, POUNCE_CC_TEXT_B_REASON), None);
5371        for (verdict, code, reason) in [
5372            (BVerdict::B, POUNCE_CC_B_VERDICT_B, None),
5373            (
5374                BVerdict::NotB {
5375                    held: vec![],
5376                    decrease: -1.0,
5377                },
5378                POUNCE_CC_B_VERDICT_NOT_B,
5379                None,
5380            ),
5381            (
5382                BVerdict::NotChecked("a branch LP did not solve"),
5383                POUNCE_CC_B_VERDICT_NOT_CHECKED,
5384                Some("a branch LP did not solve"),
5385            ),
5386        ] {
5387            out.b_check = Some(BCheck {
5388                verdict,
5389                n_biactive: 12,
5390                branches_solved: 1,
5391                enumerated: false,
5392            });
5393            let s = complementarity_stats(&out);
5394            assert_eq!(s.b_verdict, code);
5395            assert_eq!(
5396                (s.b_n_biactive, s.b_branches_solved, s.b_enumerated),
5397                (12, 1, 0)
5398            );
5399            assert_eq!(
5400                complementarity_text(&out, POUNCE_CC_TEXT_B_REASON).as_deref(),
5401                reason
5402            );
5403        }
5404    }
5405
5406    #[test]
5407    fn complementarity_encodings_polish_not_taken_has_a_reason() {
5408        use pounce_restoration::mpcc_driver::PolishRecord;
5409        let mut out = synthetic_outcome();
5410        out.polish = Some(PolishRecord {
5411            taken: false,
5412            residual_before: 1e-5,
5413            residual_after: Number::INFINITY,
5414            reason: Some("the re-solve did not converge".to_string()),
5415        });
5416        let s = complementarity_stats(&out);
5417        assert_eq!((s.polish_ran, s.polish_taken), (1, 0));
5418        assert_eq!(s.polish_residual_before, 1e-5);
5419        assert!(
5420            s.polish_residual_after.is_nan(),
5421            "unmeasured after reads NaN"
5422        );
5423        assert_eq!(
5424            complementarity_text(&out, POUNCE_CC_TEXT_POLISH_REASON).as_deref(),
5425            Some("the re-solve did not converge")
5426        );
5427        // Taken: no reason.
5428        out.polish = Some(PolishRecord {
5429            taken: true,
5430            residual_before: 1e-5,
5431            residual_after: 0.0,
5432            reason: None,
5433        });
5434        assert_eq!(complementarity_stats(&out).polish_taken, 1);
5435        assert_eq!(
5436            complementarity_text(&out, POUNCE_CC_TEXT_POLISH_REASON),
5437            None
5438        );
5439        assert_eq!(complementarity_text(&out, 3), None);
5440    }
5441
5442    #[test]
5443    fn complementarity_labels_truncate_and_terminate() {
5444        let mut a = [0x7f as c_char; 4];
5445        copy_label(&mut a, "abcdef");
5446        assert_eq!(a, [b'a' as c_char, b'b' as c_char, b'c' as c_char, 0]);
5447        copy_label(&mut a, "x");
5448        assert_eq!(a, [b'x' as c_char, 0, 0, 0], "the tail is cleared");
5449        // Every stage label fits the 32-byte fields with room for the NUL.
5450        use pounce_algorithm::mpcc::MpccStage::*;
5451        for s in [Exact, Continuation, Penalty, Branch, Polish] {
5452            assert!(s.label().len() < 32, "{}", s.label());
5453        }
5454        use pounce_algorithm::mpcc::Strategy;
5455        for s in [Strategy::Both, Strategy::Exact, Strategy::Continuation] {
5456            assert!(s.name().len() < 16, "{}", s.name());
5457        }
5458    }
5459
5460    // ----- AddPounceComplementsConstraint (gh#1147): the mapping and the
5461    // re-validation branch no public call reaches.
5462
5463    #[test]
5464    fn complements_row_reads_every_table_row_and_refuses_nan_and_crossed() {
5465        use ComplementsRow::*;
5466        let inf = 2e19;
5467        assert_eq!(complements_row(0.0, 2.0), Some(Mixed));
5468        assert_eq!(complements_row(-1.0, inf), Some(Lower));
5469        assert_eq!(complements_row(-inf, 3.0), Some(Upper));
5470        assert_eq!(complements_row(-inf, inf), Some(RowFixed));
5471        assert_eq!(complements_row(1.0, 1.0), Some(VarFixed));
5472        // The ±1e19 convention of the pair classifier: exactly 1e19 is
5473        // infinite, just inside it is finite.
5474        assert_eq!(complements_row(-1e19, 1e19), Some(RowFixed));
5475        assert_eq!(complements_row(-9.9e18, 1e19), Some(Lower));
5476        assert_eq!(complements_row(2.0, 1.0), None);
5477        assert_eq!(complements_row(f64::NAN, 1.0), None);
5478        assert_eq!(complements_row(0.0, f64::NAN), None);
5479    }
5480
5481    #[test]
5482    fn complements_edits_classify_as_the_native_shape_they_declare() {
5483        // The debug_assert in `add_complements`, as a test: for each pair
5484        // row of the table, the edited bounds of a free row are exactly the
5485        // native shape, and a no-pair row declares nothing.
5486        let inf = 2e19;
5487        for (xl, xu, want) in [
5488            (0.0, 2.0, ComplementsRow::Mixed),
5489            (-1.0, inf, ComplementsRow::Lower),
5490            (-inf, 3.0, ComplementsRow::Upper),
5491        ] {
5492            let row = complements_row(xl, xu).unwrap();
5493            assert_eq!(row, want);
5494            let (gl, gu, declares) = row.row_bounds(-inf, inf);
5495            assert!(declares);
5496            let shape = classify_pair(xl, xu, gl, gu);
5497            assert!(row.shape_matches(shape), "{row:?}: {shape:?}");
5498        }
5499        assert_eq!(
5500            ComplementsRow::RowFixed.row_bounds(-inf, inf),
5501            (0.0, 0.0, false)
5502        );
5503        assert_eq!(
5504            ComplementsRow::VarFixed.row_bounds(-inf, inf),
5505            (-inf, inf, false)
5506        );
5507        // `shape_matches` refuses a wrong shape, so the assert can fire.
5508        assert!(!ComplementsRow::Mixed.shape_matches(None));
5509        assert!(!ComplementsRow::Lower.shape_matches(classify_pair(0.0, 2.0, -inf, inf)));
5510    }
5511
5512    /// Two variables, two free rows, no callback is ever called.
5513    fn create_two_by_two(x_l: [Number; 2], x_u: [Number; 2]) -> IpoptProblem {
5514        let g_l = [-2e19, -2e19];
5515        let g_u = [2e19, 2e19];
5516        let p = unsafe {
5517            CreateIpoptProblem(
5518                2,
5519                x_l.as_ptr(),
5520                x_u.as_ptr(),
5521                2,
5522                g_l.as_ptr(),
5523                g_u.as_ptr(),
5524                0,
5525                0,
5526                0,
5527                Some(quad_eval_f),
5528                Some(cb_quad_eval_g),
5529                Some(quad_eval_grad_f),
5530                Some(cb_quad_eval_jac_g),
5531                Some(cb_quad_eval_h),
5532            )
5533        };
5534        assert!(!p.is_null());
5535        p
5536    }
5537
5538    /// Every (row, var) of the declared set and of the Complements ledger is
5539    /// found by the maps, and nothing else is.
5540    fn assert_maps_match(p: IpoptProblem) {
5541        let i = unsafe { &*p };
5542        for (k, q) in i.complementarity.iter().enumerate() {
5543            assert_eq!(i.complementarity.of_var(q.var), Some(k));
5544            assert_eq!(i.complementarity.of_row(q.row), Some(k));
5545        }
5546        for (k, u) in i.complements_used.iter().enumerate() {
5547            assert_eq!(i.complements_used.of_var(u.var), Some(k));
5548            assert_eq!(i.complements_used.of_row(u.row), Some(k));
5549        }
5550        for j in 0..i.n.max(i.m) as usize {
5551            assert_eq!(
5552                i.complementarity.of_var(j).is_some(),
5553                i.complementarity.iter().any(|q| q.var == j)
5554            );
5555            assert_eq!(
5556                i.complementarity.of_row(j).is_some(),
5557                i.complementarity.iter().any(|q| q.row == j)
5558            );
5559            assert_eq!(
5560                i.complements_used.of_var(j).is_some(),
5561                i.complements_used.iter().any(|u| u.var == j)
5562            );
5563            assert_eq!(
5564                i.complements_used.of_row(j).is_some(),
5565                i.complements_used.iter().any(|u| u.row == j)
5566            );
5567        }
5568    }
5569
5570    #[test]
5571    fn pair_maps_follow_every_declaration_path() {
5572        // error-path: AddPounceComplementarityPair
5573        // gh#1147 Stage 2 (S2-I1): the one-pair declarations find duplicates
5574        // through var/row maps instead of re-validating the stored set, so
5575        // the maps must follow Add, Set (accepted and refused), Clear and
5576        // the Complements helper, and a refused call must leave them as
5577        // they were.
5578        let p = create_two_by_two([0.0, 0.0], [2.0, 2.0]);
5579        unsafe {
5580            assert_eq!(AddPounceComplementarityPair(p, 0, 0), POUNCE_OK);
5581            assert_maps_match(p);
5582            assert_eq!(
5583                AddPounceComplementarityPair(p, 1, 0),
5584                POUNCE_ERR_VAR_ALREADY_PAIRED
5585            );
5586            assert_eq!(
5587                AddPounceComplementarityPair(p, 0, 1),
5588                POUNCE_ERR_ROW_ALREADY_PAIRED
5589            );
5590            assert_maps_match(p);
5591            let (rows, vars) = ([1, 0], [0, 1]);
5592            assert_eq!(
5593                SetPounceComplementarityPairs(
5594                    p,
5595                    2,
5596                    rows.as_ptr(),
5597                    vars.as_ptr(),
5598                    0,
5599                    std::ptr::null_mut()
5600                ),
5601                POUNCE_OK
5602            );
5603            assert_maps_match(p);
5604            assert_eq!((*p).complementarity.of_var(0), Some(0));
5605            assert_eq!((*p).complementarity.of_row(1), Some(0));
5606            let (rows, vars) = ([0, 0], [0, 1]);
5607            assert_eq!(
5608                SetPounceComplementarityPairs(
5609                    p,
5610                    2,
5611                    rows.as_ptr(),
5612                    vars.as_ptr(),
5613                    0,
5614                    std::ptr::null_mut()
5615                ),
5616                POUNCE_ERR_ROW_ALREADY_PAIRED
5617            );
5618            assert_eq!((*p).complementarity.of_row(1), Some(0));
5619            assert_maps_match(p);
5620            assert_eq!(ClearPounceComplementarityPairs(p), TRUE);
5621            assert_maps_match(p);
5622            assert_eq!((&*p).complementarity.of_var(0), None);
5623            assert_eq!(
5624                AddPounceComplementsConstraint(p, 1, 1),
5625                POUNCE_COMPLEMENTS_PAIR_MIXED
5626            );
5627            assert_maps_match(p);
5628            assert_eq!(
5629                AddPounceComplementsConstraint(p, 0, 1),
5630                POUNCE_ERR_VAR_ALREADY_PAIRED
5631            );
5632            assert_maps_match(p);
5633            FreeIpoptProblem(p);
5634        }
5635    }
5636
5637    #[test]
5638    fn complements_records_the_table_row_and_the_callers_row_bounds() {
5639        // gh#1147 Stage 2 (S2-I1): the ledger keeps what the edit replaced,
5640        // which is the user layer SetPounceBounds / GetPounceBounds serve.
5641        let p = create_two_by_two([0.0, -2e19], [2e19, 2e19]);
5642        unsafe {
5643            assert_eq!(
5644                AddPounceComplementsConstraint(p, 0, 0),
5645                POUNCE_COMPLEMENTS_PAIR_LOWER
5646            );
5647            assert_eq!(
5648                AddPounceComplementsConstraint(p, 1, 1),
5649                POUNCE_COMPLEMENTS_ROW_FIXED_NO_PAIR
5650            );
5651            let i = &*p;
5652            assert_eq!(
5653                i.complements_used[..],
5654                [
5655                    ComplementsUse {
5656                        row: 0,
5657                        var: 0,
5658                        table: ComplementsRow::Lower,
5659                        user_gl: -2e19,
5660                        user_gu: 2e19
5661                    },
5662                    ComplementsUse {
5663                        row: 1,
5664                        var: 1,
5665                        table: ComplementsRow::RowFixed,
5666                        user_gl: -2e19,
5667                        user_gu: 2e19
5668                    },
5669                ]
5670            );
5671            assert_eq!((i.g_l[0], i.g_u[0]), (0.0, 2e19));
5672            assert_eq!((i.g_l[1], i.g_u[1]), (0.0, 0.0));
5673            FreeIpoptProblem(p);
5674        }
5675    }
5676}