Skip to main content

pounce_nlp/
orig_ipopt_nlp.rs

1//! `OrigIpoptNlp` — concrete `IpoptNlp` impl that wraps a [`TNLPAdapter`]
2//! and an [`NlpScaling`] object. Port of
3//! `Algorithm/IpOrigIpoptNLP.{hpp,cpp}` (Ipopt 3.14.19).
4//!
5//! # Design
6//!
7//! Upstream `OrigIpoptNLP` does four things:
8//!
9//! 1. Holds the equality / inequality-separated bound vectors
10//!    (`x_L`, `x_U`, `d_L`, `d_U`) and the four expansion matrices
11//!    (`Px_L`, `Px_U`, `Pd_L`, `Pd_U`).
12//! 2. Routes `f / grad_f / c / d / jac_c / jac_d / h` evaluations down
13//!    to the user's `NLP` (i.e. our `TNLP` via `TNLPAdapter`), splitting
14//!    constraints into c/d and applying scaling.
15//! 3. Caches each result keyed on the input vector tag (`CachedResults`
16//!    upstream → [`pounce_common::cached::Cache`] here).
17//! 4. Counts evaluations and forwards the unscaled solution to
18//!    `TNLP::finalize_solution`.
19//!
20//! # Trait location
21//!
22//! The `Nlp` / `IpoptNlp` traits live in [`crate::ipopt_nlp`] and are
23//! re-exported from `pounce_algorithm::ipopt_nlp` so the algorithm-side
24//! code can keep its existing `crate::ipopt_nlp::IpoptNlp` import path.
25//! We moved the traits down to `pounce-nlp` because the concrete impl
26//! has to live alongside `TNLPAdapter` (its private dependency) and
27//! `pounce-algorithm` already depends on `pounce-nlp` (the reverse
28//! would cycle).
29//!
30//! # Phase scope
31//!
32//! Implemented for v1.0:
33//! * `f / grad_f / c / d / jac_c / jac_d / h` with one-dependency
34//!   tag-keyed caches.
35//! * Bound vectors and 0/1 expansion matrices for `(x_L, x_U, d_L, d_U)`.
36//! * Starting point retrieval + initial multiplier handling.
37//! * Per-eval counters (used to populate `SolveStatistics`).
38//! * `finalize_solution` plumbing.
39//!
40//! Deferred (phase numbers from
41//! `we-are-going-to-polished-simon.md`):
42//! * Phase 8: L-BFGS / SR1 quasi-Newton path
43//!   (`hessian_approximation = limited-memory`). The `eval_h` path is
44//!   wired here, but the `LowRankUpdateSymMatrix` h_space construction
45//!   in `IpOrigIpoptNLP.cpp:251-278` is not.
46//! * Phase 10: adaptive-mu's `objective_depends_on_mu` /
47//!   `f(x, mu)` overload (CG-penalty objective).
48//! * Bound relaxation (`bound_relax_factor`) and `honor_original_bounds`
49//!   projection — these need `OptionsList` plumbing that lands later.
50//! * `check_derivatives_for_naninf` — needs the journalist's NaN
51//!   reporting, deferred with the option.
52//! * Full `NLPScalingObject` integration (currently only `obj_scaling`
53//!   is used; `apply_vector_scaling_*`, `apply_jac_*_scaling`,
54//!   `apply_hessian_scaling` live behind future scaling-object API).
55//! * Fixed-variable removal (`x_l == x_u`) — `TNLPAdapter` keeps fixed
56//!   variables in `x_var` for now; the upstream
57//!   `fixed_variable_treatment` knob lands when the option machinery
58//!   does.
59
60use crate::ipopt_nlp::{IpoptNlp, Nlp};
61use crate::tnlp::{NlpInfo, SparsityRequest, StartingPoint};
62use crate::tnlp_adapter::{BoundClassification, TNLPAdapter};
63use pounce_common::cached::Cache;
64use pounce_common::timing::TimingStatistics;
65use pounce_common::types::{Index, Number};
66use pounce_linalg::{
67    DenseVector, DenseVectorSpace, ExpansionMatrix, ExpansionMatrixSpace, GenTMatrix,
68    GenTMatrixSpace, Matrix, SymMatrix, SymTMatrix, SymTMatrixSpace, Vector,
69};
70use std::cell::{Cell, RefCell};
71use std::rc::Rc;
72
73/// Opaque scaling-object handle. `OrigIpoptNlp` only consults this for
74/// the *initial* objective scaling; the full per-row constraint /
75/// Jacobian / Hessian scaling lives directly on `OrigIpoptNlp` (see
76/// the `obj_scale_factor` / `c_scale` / `d_scale` fields and the
77/// `determine_scaling_from_starting_point` method) so that the runtime
78/// can compute gradient-based scaling without an upcall.
79///
80/// The trait is intentionally minimal and local to `pounce-nlp`: the
81/// gradient-based scaling arithmetic lives on `OrigIpoptNlp` itself
82/// (see `determine_scaling_from_starting_point`), so there is no
83/// algorithm-layer scaling strategy object.
84pub trait NlpScaling {
85    /// Optional user-supplied multiplier on the objective scaling
86    /// factor. Mirrors upstream's `obj_scaling_factor` option (default
87    /// 1.0). Combined with the gradient-based factor in
88    /// `OrigIpoptNlp::determine_scaling_from_starting_point`.
89    fn obj_scaling(&self) -> Number {
90        1.0
91    }
92}
93
94/// No-op scaling — every factor is 1.0. Default for unit tests and
95/// callers that have not configured a scaling strategy.
96#[derive(Debug, Default, Clone, Copy)]
97pub struct NoScaling;
98impl NlpScaling for NoScaling {}
99
100/// Selector for [`OrigIpoptNlp::determine_scaling_from_starting_point`].
101/// Mirrors upstream's `nlp_scaling_method` option.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ScalingMethod {
104    /// No automatic scaling beyond the constant `obj_scaling_factor`.
105    None,
106    /// Gradient-based per `Algorithm/IpGradientScaling.cpp`. Default.
107    GradientBased,
108}
109
110/// Concrete `IpoptNlp` over a `TNLPAdapter`. Mirrors upstream
111/// `Ipopt::OrigIpoptNLP`.
112pub struct OrigIpoptNlp {
113    /// Backing TNLP (and its bound classification).
114    adapter: Rc<RefCell<TNLPAdapter>>,
115    /// Constant objective-scaling multiplier supplied by the user
116    /// (mirrors upstream's `obj_scaling_factor` option). The
117    /// gradient-based factor is multiplied into [`obj_scale_factor`]
118    /// after [`Self::determine_scaling_from_starting_point`] runs.
119    scaling: Rc<dyn NlpScaling>,
120
121    // ----- gradient-based scaling state (port of `IpGradientScaling.cpp`) -----
122    /// Effective objective scaling factor `df_` (1.0 when no scaling).
123    obj_scale_factor: Cell<Number>,
124    /// Per-row scaling for equality constraints (`dc_`). `None` ↔
125    /// `IsValid(dc) == false` — i.e. row-max gradient is below the
126    /// `nlp_scaling_max_gradient` cutoff so no scaling is applied.
127    c_scale: RefCell<Option<Vec<Number>>>,
128    /// Same as [`Self::c_scale`] but for inequality rows.
129    d_scale: RefCell<Option<Vec<Number>>>,
130
131    // ----- vector / matrix spaces (shared via Rc) -----
132    x_space: Rc<DenseVectorSpace>,
133    c_space: Rc<DenseVectorSpace>,
134    d_space: Rc<DenseVectorSpace>,
135    x_l_space: Rc<DenseVectorSpace>,
136    x_u_space: Rc<DenseVectorSpace>,
137    d_l_space: Rc<DenseVectorSpace>,
138    d_u_space: Rc<DenseVectorSpace>,
139    px_l_space: Rc<ExpansionMatrixSpace>,
140    px_u_space: Rc<ExpansionMatrixSpace>,
141    pd_l_space: Rc<ExpansionMatrixSpace>,
142    pd_u_space: Rc<ExpansionMatrixSpace>,
143    jac_c_space: Rc<GenTMatrixSpace>,
144    jac_d_space: Rc<GenTMatrixSpace>,
145    /// Hessian space; `None` when `eval_h` is not provided by the TNLP
146    /// (the limited-memory quasi-Newton path lands in Phase 8).
147    h_space: Option<Rc<SymTMatrixSpace>>,
148
149    // ----- bound vectors (compressed-x sub-spaces) -----
150    x_l: Rc<DenseVector>,
151    x_u: Rc<DenseVector>,
152    d_l: Rc<DenseVector>,
153    d_u: Rc<DenseVector>,
154
155    // ----- expansion matrices (instances; spaces above) -----
156    px_l: Rc<dyn Matrix>,
157    px_u: Rc<dyn Matrix>,
158    pd_l: Rc<dyn Matrix>,
159    pd_u: Rc<dyn Matrix>,
160
161    // ----- jacobian sparsity remap -----
162    /// `jac_c_entry_in_g[k]` = position in the full TNLP jacobian's
163    /// values array of the k-th equality-row entry.
164    jac_c_entry_in_g: Vec<Index>,
165    /// Same for inequality rows.
166    jac_d_entry_in_g: Vec<Index>,
167    /// Total nonzeros in the full (un-split) `eval_jac_g` triplet.
168    nnz_jac_g_full: Index,
169
170    // ----- hessian sparsity remap (fixed-var filtering) -----
171    /// Total nonzeros the user's `eval_h` writes into. May exceed
172    /// `h_space.nonzeros()` when fixed variables drop entries.
173    nnz_h_lag_full: Index,
174    /// `h_entry_in_full[k]` = position in the full TNLP hessian's
175    /// values array of the k-th kept entry. Always has length
176    /// `h_space.nonzeros()`; equals the identity `[0, 1, …, n-1]`
177    /// when no fixed-var filtering dropped any entries.
178    h_entry_in_full: Vec<Index>,
179
180    // ----- caches (one entry; key = input vector tag) -----
181    f_cache: RefCell<Cache<Number>>,
182    grad_f_cache: RefCell<Cache<Rc<dyn Vector>>>,
183    c_cache: RefCell<Cache<Rc<dyn Vector>>>,
184    d_cache: RefCell<Cache<Rc<dyn Vector>>>,
185    jac_c_cache: RefCell<Cache<Rc<dyn Matrix>>>,
186    jac_d_cache: RefCell<Cache<Rc<dyn Matrix>>>,
187    h_cache: RefCell<Cache<Rc<dyn SymMatrix>>>,
188
189    // ----- evaluation counters -----
190    f_evals: RefCell<Index>,
191    grad_f_evals: RefCell<Index>,
192    c_evals: RefCell<Index>,
193    d_evals: RefCell<Index>,
194    jac_c_evals: RefCell<Index>,
195    jac_d_evals: RefCell<Index>,
196    h_evals: RefCell<Index>,
197
198    /// Cached `NlpInfo` (n, m, nnz_jac_g, nnz_h_lag, index_style) so we
199    /// don't re-borrow the TNLP for dimension queries.
200    info: NlpInfo,
201
202    /// Shared per-subsystem timing accumulator. `None` until
203    /// `IpoptApplication` installs the shared `Rc<TimingStatistics>` via
204    /// [`Self::set_timing_stats`]; when `None`, all `eval_*` entry
205    /// points skip the timing overhead.
206    timing: RefCell<Option<Rc<TimingStatistics>>>,
207}
208
209impl std::fmt::Debug for OrigIpoptNlp {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("OrigIpoptNlp")
212            .field("info", &self.info)
213            .field("f_evals", &*self.f_evals.borrow())
214            .field("grad_f_evals", &*self.grad_f_evals.borrow())
215            .field("c_evals", &*self.c_evals.borrow())
216            .field("d_evals", &*self.d_evals.borrow())
217            .field("jac_c_evals", &*self.jac_c_evals.borrow())
218            .field("jac_d_evals", &*self.jac_d_evals.borrow())
219            .field("h_evals", &*self.h_evals.borrow())
220            .finish_non_exhaustive()
221    }
222}
223
224impl OrigIpoptNlp {
225    /// Construct an `OrigIpoptNlp` from a (already-classified) adapter
226    /// and a scaling object. Mirrors
227    /// `OrigIpoptNLP::OrigIpoptNLP` + `InitializeStructures`
228    /// (`IpOrigIpoptNLP.cpp:22-457`) — the parts that don't need an
229    /// `OptionsList` (those land with the option-machinery integration).
230    pub fn new(
231        adapter: Rc<RefCell<TNLPAdapter>>,
232        scaling: Rc<dyn NlpScaling>,
233    ) -> Result<Self, String> {
234        // Snapshot dimensions / classification from the adapter.
235        let (info, classification) = {
236            let a = adapter.borrow();
237            (*a.nlp_info(), a.classification().clone())
238        };
239
240        // ---- Vector spaces ----
241        let n_x_var = classification.n_x_var();
242        let x_space = DenseVectorSpace::new(n_x_var);
243        let c_space = DenseVectorSpace::new(classification.n_c);
244        let d_space = DenseVectorSpace::new(classification.n_d);
245        let x_l_space = DenseVectorSpace::new(classification.n_x_l());
246        let x_u_space = DenseVectorSpace::new(classification.n_x_u());
247        let d_l_space = DenseVectorSpace::new(classification.n_d_l());
248        let d_u_space = DenseVectorSpace::new(classification.n_d_u());
249
250        // ---- Expansion matrix spaces (column-compressed → full x_var / d) ----
251        let px_l_space =
252            ExpansionMatrixSpace::new(n_x_var, classification.n_x_l(), &classification.x_l_map, 0);
253        let px_u_space =
254            ExpansionMatrixSpace::new(n_x_var, classification.n_x_u(), &classification.x_u_map, 0);
255        let pd_l_space = ExpansionMatrixSpace::new(
256            classification.n_d,
257            classification.n_d_l(),
258            &classification.d_l_map,
259            0,
260        );
261        let pd_u_space = ExpansionMatrixSpace::new(
262            classification.n_d,
263            classification.n_d_u(),
264            &classification.d_u_map,
265            0,
266        );
267        let px_l: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&px_l_space)));
268        let px_u: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&px_u_space)));
269        let pd_l: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&pd_l_space)));
270        let pd_u: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&pd_u_space)));
271
272        // ---- Bound vectors. Pull the full `x_l/x_u/g_l/g_u` arrays from
273        // the TNLP (the adapter discarded them after classification) and
274        // pick out the entries pointed at by the `*_map` tables. -----
275        let n_full_x = classification.n_full_x as usize;
276        let n_full_g = classification.n_full_g as usize;
277        let mut full_x_l = vec![0.0; n_full_x];
278        let mut full_x_u = vec![0.0; n_full_x];
279        let mut full_g_l = vec![0.0; n_full_g];
280        let mut full_g_u = vec![0.0; n_full_g];
281        {
282            let a = adapter.borrow();
283            let mut t = a.tnlp().borrow_mut();
284            let ok = t.get_bounds_info(crate::tnlp::BoundsInfo {
285                x_l: &mut full_x_l,
286                x_u: &mut full_x_u,
287                g_l: &mut full_g_l,
288                g_u: &mut full_g_u,
289            });
290            if !ok {
291                return Err("TNLP::get_bounds_info returned false on second call".into());
292            }
293        }
294
295        let x_l = make_dense_from(&x_l_space, |i| {
296            // x_l_map[i] is an index into x_var (== index into x_not_fixed_map).
297            let var_idx = classification.x_l_map[i] as usize;
298            let full_idx = classification.x_not_fixed_map[var_idx] as usize;
299            full_x_l[full_idx]
300        });
301        let x_u = make_dense_from(&x_u_space, |i| {
302            let var_idx = classification.x_u_map[i] as usize;
303            let full_idx = classification.x_not_fixed_map[var_idx] as usize;
304            full_x_u[full_idx]
305        });
306        let d_l = make_dense_from(&d_l_space, |i| {
307            // d_l_map[i] is an index into d (== position in d_map).
308            let d_idx = classification.d_l_map[i] as usize;
309            let full_g_idx = classification.d_map[d_idx] as usize;
310            full_g_l[full_g_idx]
311        });
312        let d_u = make_dense_from(&d_u_space, |i| {
313            let d_idx = classification.d_u_map[i] as usize;
314            let full_g_idx = classification.d_map[d_idx] as usize;
315            full_g_u[full_g_idx]
316        });
317
318        // ---- Jacobian sparsity. Ask the TNLP for the full jacobian
319        // structure (g rows × full-x cols), then split entries into
320        // c-rows (equality) and d-rows (inequality). Within each split
321        // the row index is remapped to the new dense indexing in
322        // [0, n_c) / [0, n_d).
323        //
324        // We currently keep all original full-x columns (no fixed-var
325        // removal); when fixed-var treatment lands, this is where the
326        // column remap goes. -----
327        let mut full_irow = vec![0 as Index; info.nnz_jac_g as usize];
328        let mut full_jcol = vec![0 as Index; info.nnz_jac_g as usize];
329        {
330            let a = adapter.borrow();
331            let mut t = a.tnlp().borrow_mut();
332            let ok = t.eval_jac_g(
333                None,
334                false,
335                SparsityRequest::Structure {
336                    irow: &mut full_irow,
337                    jcol: &mut full_jcol,
338                },
339            );
340            if !ok {
341                return Err("TNLP::eval_jac_g(Structure) returned false".into());
342            }
343        }
344
345        // Build the inverse maps: g-row → c-row (or d-row).
346        let mut g_to_c = vec![-1 as Index; n_full_g];
347        for (c_idx, &g_idx) in classification.c_map.iter().enumerate() {
348            g_to_c[g_idx as usize] = c_idx as Index;
349        }
350        let mut g_to_d = vec![-1 as Index; n_full_g];
351        for (d_idx, &g_idx) in classification.d_map.iter().enumerate() {
352            g_to_d[g_idx as usize] = d_idx as Index;
353        }
354
355        let style_offset = match info.index_style {
356            crate::tnlp::IndexStyle::C => 0 as Index,
357            crate::tnlp::IndexStyle::Fortran => 1 as Index,
358        };
359
360        let mut jac_c_irow_1based = Vec::new();
361        let mut jac_c_jcol_1based = Vec::new();
362        let mut jac_c_entry_in_g = Vec::new();
363        let mut jac_d_irow_1based = Vec::new();
364        let mut jac_d_jcol_1based = Vec::new();
365        let mut jac_d_entry_in_g = Vec::new();
366
367        // `make_parameter`: drop Jacobian entries in fixed-variable
368        // columns. Their contribution to f and g is constant under the
369        // active-x search so they don't appear in the KKT.
370        let full_to_var = &classification.full_to_var;
371        for k in 0..info.nnz_jac_g as usize {
372            let g_row_0 = (full_irow[k] - style_offset) as usize;
373            let x_col_0 = (full_jcol[k] - style_offset) as usize;
374            let var_col = full_to_var[x_col_0];
375            if var_col < 0 {
376                continue;
377            }
378            // Triplet output is 1-based (matches `GenTMatrix` convention).
379            let col_1based = var_col + 1;
380            let c_row = g_to_c[g_row_0];
381            if c_row >= 0 {
382                jac_c_irow_1based.push(c_row + 1);
383                jac_c_jcol_1based.push(col_1based);
384                jac_c_entry_in_g.push(k as Index);
385            } else {
386                let d_row = g_to_d[g_row_0];
387                debug_assert!(d_row >= 0, "g row {g_row_0} is neither in c_map nor d_map");
388                jac_d_irow_1based.push(d_row + 1);
389                jac_d_jcol_1based.push(col_1based);
390                jac_d_entry_in_g.push(k as Index);
391            }
392        }
393
394        let jac_c_space = GenTMatrixSpace::new(
395            classification.n_c,
396            n_x_var,
397            jac_c_irow_1based,
398            jac_c_jcol_1based,
399        );
400        let jac_d_space = GenTMatrixSpace::new(
401            classification.n_d,
402            n_x_var,
403            jac_d_irow_1based,
404            jac_d_jcol_1based,
405        );
406
407        // ---- Hessian sparsity (optional). If the TNLP doesn't
408        // implement `eval_h`, we leave `h_space = None`. The Phase-8
409        // limited-memory quasi-Newton path will populate it from
410        // `LowRankUpdateSymMatrixSpace` instead. -----
411        let nnz_h_lag_full = info.nnz_h_lag;
412        let mut h_entry_in_full: Vec<Index> = Vec::new();
413        let h_space = if info.nnz_h_lag > 0 {
414            let mut h_irow = vec![0 as Index; info.nnz_h_lag as usize];
415            let mut h_jcol = vec![0 as Index; info.nnz_h_lag as usize];
416            let supports_h = {
417                let a = adapter.borrow();
418                let mut t = a.tnlp().borrow_mut();
419                t.eval_h(
420                    None,
421                    false,
422                    1.0,
423                    None,
424                    false,
425                    SparsityRequest::Structure {
426                        irow: &mut h_irow,
427                        jcol: &mut h_jcol,
428                    },
429                )
430            };
431            if supports_h {
432                // `make_parameter`: drop Hessian entries where row OR
433                // column is fixed (the second derivatives w.r.t. a
434                // parameter are not needed in the active-x KKT). The
435                // surviving entries are remapped from full-x indices
436                // to var-x indices via `full_to_var`.
437                let mut h_irow_1: Vec<Index> = Vec::with_capacity(h_irow.len());
438                let mut h_jcol_1: Vec<Index> = Vec::with_capacity(h_jcol.len());
439                for k in 0..h_irow.len() {
440                    let i_full = (h_irow[k] - style_offset) as usize;
441                    let j_full = (h_jcol[k] - style_offset) as usize;
442                    let i_var = full_to_var[i_full];
443                    let j_var = full_to_var[j_full];
444                    if i_var < 0 || j_var < 0 {
445                        continue;
446                    }
447                    h_irow_1.push(i_var + 1);
448                    h_jcol_1.push(j_var + 1);
449                    h_entry_in_full.push(k as Index);
450                }
451                Some(SymTMatrixSpace::new(n_x_var, h_irow_1, h_jcol_1))
452            } else {
453                // TODO(Phase 8): wire the L-BFGS / SR1 path here.
454                None
455            }
456        } else {
457            // LPs and other problems with structurally zero Hessian: build an
458            // empty SymTMatrixSpace so eval_h_internal returns a zero matrix
459            // rather than panicking down the L-BFGS error path.
460            Some(SymTMatrixSpace::new(n_x_var, Vec::new(), Vec::new()))
461        };
462
463        Ok(Self {
464            adapter,
465            scaling,
466            obj_scale_factor: Cell::new(1.0),
467            c_scale: RefCell::new(None),
468            d_scale: RefCell::new(None),
469            x_space,
470            c_space,
471            d_space,
472            x_l_space,
473            x_u_space,
474            d_l_space,
475            d_u_space,
476            px_l_space,
477            px_u_space,
478            pd_l_space,
479            pd_u_space,
480            jac_c_space,
481            jac_d_space,
482            h_space,
483            x_l: Rc::new(x_l),
484            x_u: Rc::new(x_u),
485            d_l: Rc::new(d_l),
486            d_u: Rc::new(d_u),
487            px_l,
488            px_u,
489            pd_l,
490            pd_u,
491            jac_c_entry_in_g,
492            jac_d_entry_in_g,
493            nnz_jac_g_full: info.nnz_jac_g,
494            nnz_h_lag_full,
495            h_entry_in_full,
496            f_cache: RefCell::new(Cache::new(1)),
497            grad_f_cache: RefCell::new(Cache::new(1)),
498            c_cache: RefCell::new(Cache::new(1)),
499            d_cache: RefCell::new(Cache::new(1)),
500            jac_c_cache: RefCell::new(Cache::new(1)),
501            jac_d_cache: RefCell::new(Cache::new(1)),
502            h_cache: RefCell::new(Cache::new(1)),
503            f_evals: RefCell::new(0),
504            grad_f_evals: RefCell::new(0),
505            c_evals: RefCell::new(0),
506            d_evals: RefCell::new(0),
507            jac_c_evals: RefCell::new(0),
508            jac_d_evals: RefCell::new(0),
509            h_evals: RefCell::new(0),
510            info,
511            timing: RefCell::new(None),
512        })
513    }
514
515    /// Install the shared timing accumulator. `IpoptApplication` calls
516    /// this once per solve so each `eval_*` entrypoint records into the
517    /// same `TimingStatistics` instance the algorithm reports at the
518    /// end of the run. Calling with `None` (or never calling) leaves
519    /// timing disabled.
520    pub fn set_timing_stats(&self, t: Rc<TimingStatistics>) {
521        *self.timing.borrow_mut() = Some(t);
522    }
523
524    /// Run `f` with two timers active for the duration of the call:
525    /// `pick(&timing)` (e.g. `eval_obj`) and `total_function_evaluation_time`.
526    /// When no `TimingStatistics` is installed, the closure is invoked
527    /// directly with no overhead.
528    fn timed_eval<R, F>(&self, pick: fn(&TimingStatistics) -> &pounce_common::TimedTask, f: F) -> R
529    where
530        F: FnOnce() -> R,
531    {
532        let guard = self.timing.borrow();
533        match guard.as_deref() {
534            Some(t) => {
535                let task = pick(t);
536                task.start();
537                t.total_function_evaluation_time.start();
538                let r = f();
539                t.total_function_evaluation_time.end();
540                task.end();
541                r
542            }
543            None => {
544                drop(guard);
545                f()
546            }
547        }
548    }
549
550    // ---- accessors used by the algorithm wiring layer ----
551
552    pub fn nlp_info(&self) -> &NlpInfo {
553        &self.info
554    }
555    pub fn classification_n_x_var(&self) -> Index {
556        self.x_space.dim()
557    }
558    pub fn x_space(&self) -> &Rc<DenseVectorSpace> {
559        &self.x_space
560    }
561    pub fn c_space(&self) -> &Rc<DenseVectorSpace> {
562        &self.c_space
563    }
564    pub fn d_space(&self) -> &Rc<DenseVectorSpace> {
565        &self.d_space
566    }
567    pub fn x_l_space(&self) -> &Rc<DenseVectorSpace> {
568        &self.x_l_space
569    }
570    pub fn x_u_space(&self) -> &Rc<DenseVectorSpace> {
571        &self.x_u_space
572    }
573    pub fn d_l_space(&self) -> &Rc<DenseVectorSpace> {
574        &self.d_l_space
575    }
576    pub fn d_u_space(&self) -> &Rc<DenseVectorSpace> {
577        &self.d_u_space
578    }
579    pub fn px_l_space(&self) -> &Rc<ExpansionMatrixSpace> {
580        &self.px_l_space
581    }
582    pub fn px_u_space(&self) -> &Rc<ExpansionMatrixSpace> {
583        &self.px_u_space
584    }
585    pub fn pd_l_space(&self) -> &Rc<ExpansionMatrixSpace> {
586        &self.pd_l_space
587    }
588    pub fn pd_u_space(&self) -> &Rc<ExpansionMatrixSpace> {
589        &self.pd_u_space
590    }
591    pub fn jac_c_space(&self) -> &Rc<GenTMatrixSpace> {
592        &self.jac_c_space
593    }
594    pub fn jac_d_space(&self) -> &Rc<GenTMatrixSpace> {
595        &self.jac_d_space
596    }
597    pub fn h_space(&self) -> Option<&Rc<SymTMatrixSpace>> {
598        self.h_space.as_ref()
599    }
600
601    /// Effective objective scaling factor (`df_` upstream). 1.0 when
602    /// no scaling has been determined.
603    pub fn obj_scale_factor(&self) -> Number {
604        self.obj_scale_factor.get()
605    }
606
607    /// Apply `bound_relax_factor` to the unscaled `x_L / x_U / d_L / d_U`
608    /// in place. Mirrors `OrigIpoptNLP::relax_bounds`
609    /// (`IpOrigIpoptNLP.cpp:343-358, 459-481`):
610    ///
611    /// `delta_i = min(constr_viol_tol, |relax| * max(|bound_i|, 1))`,
612    /// then `x_L -= delta`, `x_U += delta`, `d_L -= delta`, `d_U += delta`.
613    ///
614    /// Must be called before `determine_scaling_from_starting_point`
615    /// (which only reads bounds via cached evals — so order doesn't
616    /// affect scaling — but the bounds themselves should be the
617    /// post-relax values when they enter the algorithm).
618    pub fn relax_bounds(&mut self, bound_relax_factor: Number, constr_viol_tol: Number) {
619        if bound_relax_factor <= 0.0 {
620            return;
621        }
622        let relax = bound_relax_factor.abs();
623        let cap = constr_viol_tol;
624        let apply = |v: &mut DenseVector, sign: Number| {
625            let xs = v.values_mut();
626            for x in xs.iter_mut() {
627                let delta = (relax * x.abs().max(1.0)).min(cap);
628                *x += sign * delta;
629            }
630        };
631        if let Some(x_l) = Rc::get_mut(&mut self.x_l) {
632            apply(x_l, -1.0);
633        }
634        if let Some(x_u) = Rc::get_mut(&mut self.x_u) {
635            apply(x_u, 1.0);
636        }
637        if let Some(d_l) = Rc::get_mut(&mut self.d_l) {
638            apply(d_l, -1.0);
639        }
640        if let Some(d_u) = Rc::get_mut(&mut self.d_u) {
641            apply(d_u, 1.0);
642        }
643    }
644
645    /// Gradient-based determination of `df_`, `dc_`, `dd_` per
646    /// `Algorithm/IpGradientScaling.cpp::DetermineScalingParametersImpl`.
647    /// Should be called once, after construction and before the
648    /// algorithm enters its main loop.
649    ///
650    /// `max_gradient` is `nlp_scaling_max_gradient` (cutoff above which
651    /// scaling is applied; default 100). `min_value` is
652    /// `nlp_scaling_min_value` (floor on computed scale factors; default
653    /// 1e-8). Cache state is invalidated so subsequent eval calls
654    /// produce scaled values.
655    pub fn determine_scaling_from_starting_point(
656        &self,
657        method: ScalingMethod,
658        max_gradient: Number,
659        min_value: Number,
660    ) {
661        // Always pull the user's `obj_scaling_factor` constant first;
662        // it multiplies whatever the gradient-based scheme computes.
663        let user_obj_factor = self.scaling.obj_scaling();
664        if matches!(method, ScalingMethod::None) {
665            self.obj_scale_factor.set(user_obj_factor);
666            *self.c_scale.borrow_mut() = None;
667            *self.d_scale.borrow_mut() = None;
668            self.invalidate_eval_caches();
669            return;
670        }
671
672        // ---- Get starting x_full -----
673        let cls = self.adapter.borrow().classification().clone();
674        let n_full_x = cls.n_full_x as usize;
675        let n_full_g = cls.n_full_g as usize;
676        let mut full_x = vec![0.0; n_full_x];
677        let mut full_z_l = vec![0.0; n_full_x];
678        let mut full_z_u = vec![0.0; n_full_x];
679        let mut full_lambda = vec![0.0; n_full_g];
680        let starting_ok = {
681            let a = self.adapter.borrow();
682            let mut t = a.tnlp().borrow_mut();
683            t.get_starting_point(StartingPoint {
684                init_x: true,
685                x: &mut full_x,
686                init_z: false,
687                z_l: &mut full_z_l,
688                z_u: &mut full_z_u,
689                init_lambda: false,
690                lambda: &mut full_lambda,
691            })
692        };
693        if !starting_ok {
694            // Fall back to no automatic scaling.
695            self.obj_scale_factor.set(user_obj_factor);
696            *self.c_scale.borrow_mut() = None;
697            *self.d_scale.borrow_mut() = None;
698            self.invalidate_eval_caches();
699            return;
700        }
701
702        // ---- Objective gradient scale ----
703        let mut full_grad_f = vec![0.0; n_full_x];
704        let grad_ok = {
705            let a = self.adapter.borrow();
706            let mut t = a.tnlp().borrow_mut();
707            t.eval_grad_f(&full_x, true, &mut full_grad_f)
708        };
709        let mut df = 1.0;
710        if grad_ok {
711            // Amax over the *compressed* x_var space (matches upstream
712            // which scales the algorithm-side gradient).
713            let mut max_grad_f: Number = 0.0;
714            for &full_idx in cls.x_not_fixed_map.iter() {
715                let v = full_grad_f[full_idx as usize].abs();
716                if v > max_grad_f {
717                    max_grad_f = v;
718                }
719            }
720            if max_grad_f > max_gradient {
721                df = max_gradient / max_grad_f;
722            }
723            if df < min_value {
724                df = min_value;
725            }
726        }
727        self.obj_scale_factor.set(df * user_obj_factor);
728
729        // ---- Constraint Jacobian row-max scaling ----
730        if cls.n_full_g > 0 {
731            // Evaluate full Jacobian once at x.
732            let mut full_jac_vals = vec![0.0; self.nnz_jac_g_full as usize];
733            let jac_ok = {
734                let a = self.adapter.borrow();
735                let mut t = a.tnlp().borrow_mut();
736                t.eval_jac_g(
737                    Some(&full_x),
738                    true,
739                    SparsityRequest::Values {
740                        values: &mut full_jac_vals,
741                    },
742                )
743            };
744            if jac_ok {
745                // Recover row indices from the sparsity structure.
746                let mut full_irow = vec![0 as Index; self.nnz_jac_g_full as usize];
747                let mut full_jcol = vec![0 as Index; self.nnz_jac_g_full as usize];
748                let _ = {
749                    let a = self.adapter.borrow();
750                    let mut t = a.tnlp().borrow_mut();
751                    t.eval_jac_g(
752                        None,
753                        false,
754                        SparsityRequest::Structure {
755                            irow: &mut full_irow,
756                            jcol: &mut full_jcol,
757                        },
758                    )
759                };
760                let style_offset: Index = match self.info.index_style {
761                    crate::tnlp::IndexStyle::C => 0,
762                    crate::tnlp::IndexStyle::Fortran => 1,
763                };
764                // Build inverse row maps to assign each entry to c or d.
765                let mut g_to_c = vec![-1 as Index; n_full_g];
766                for (c_idx, &g_idx) in cls.c_map.iter().enumerate() {
767                    g_to_c[g_idx as usize] = c_idx as Index;
768                }
769                let mut g_to_d = vec![-1 as Index; n_full_g];
770                for (d_idx, &g_idx) in cls.d_map.iter().enumerate() {
771                    g_to_d[g_idx as usize] = d_idx as Index;
772                }
773                let n_c = cls.n_c as usize;
774                let n_d = cls.n_d as usize;
775                // Initialize row-max arrays to dbl_min as upstream does.
776                let dbl_min = Number::MIN_POSITIVE;
777                let mut c_row_max: Vec<Number> = vec![dbl_min; n_c];
778                let mut d_row_max: Vec<Number> = vec![dbl_min; n_d];
779                for k in 0..self.nnz_jac_g_full as usize {
780                    let g_row_0 = (full_irow[k] - style_offset) as usize;
781                    let v = full_jac_vals[k].abs();
782                    let cr = g_to_c[g_row_0];
783                    if cr >= 0 {
784                        let row = cr as usize;
785                        if v > c_row_max[row] {
786                            c_row_max[row] = v;
787                        }
788                    } else {
789                        let dr = g_to_d[g_row_0];
790                        if dr >= 0 {
791                            let row = dr as usize;
792                            if v > d_row_max[row] {
793                                d_row_max[row] = v;
794                            }
795                        }
796                    }
797                }
798
799                // c-scale: only populated if any row exceeds the cutoff.
800                if n_c > 0 {
801                    let mut any_above = false;
802                    for &v in c_row_max.iter() {
803                        if v > max_gradient {
804                            any_above = true;
805                            break;
806                        }
807                    }
808                    if any_above {
809                        let mut dc = vec![0.0; n_c];
810                        for i in 0..n_c {
811                            // dc = max_gradient / row_max, capped at 1
812                            // (ElementWiseMin with Set(1.0) upstream).
813                            let mut s = max_gradient / c_row_max[i];
814                            if s > 1.0 {
815                                s = 1.0;
816                            }
817                            if s < min_value {
818                                s = min_value;
819                            }
820                            dc[i] = s;
821                        }
822                        *self.c_scale.borrow_mut() = Some(dc);
823                    } else {
824                        *self.c_scale.borrow_mut() = None;
825                    }
826                } else {
827                    *self.c_scale.borrow_mut() = None;
828                }
829
830                if n_d > 0 {
831                    let mut any_above = false;
832                    for &v in d_row_max.iter() {
833                        if v > max_gradient {
834                            any_above = true;
835                            break;
836                        }
837                    }
838                    if any_above {
839                        let mut dd = vec![0.0; n_d];
840                        for i in 0..n_d {
841                            let mut s = max_gradient / d_row_max[i];
842                            if s > 1.0 {
843                                s = 1.0;
844                            }
845                            if s < min_value {
846                                s = min_value;
847                            }
848                            dd[i] = s;
849                        }
850                        *self.d_scale.borrow_mut() = Some(dd);
851                    } else {
852                        *self.d_scale.borrow_mut() = None;
853                    }
854                } else {
855                    *self.d_scale.borrow_mut() = None;
856                }
857            }
858        }
859
860        // Drop any cached eval results computed before the scales were
861        // set (their values would be wrong now).
862        self.invalidate_eval_caches();
863    }
864
865    fn invalidate_eval_caches(&self) {
866        self.f_cache.borrow_mut().clear();
867        self.grad_f_cache.borrow_mut().clear();
868        self.c_cache.borrow_mut().clear();
869        self.d_cache.borrow_mut().clear();
870        self.jac_c_cache.borrow_mut().clear();
871        self.jac_d_cache.borrow_mut().clear();
872        self.h_cache.borrow_mut().clear();
873    }
874
875    pub fn f_evals(&self) -> Index {
876        *self.f_evals.borrow()
877    }
878    pub fn grad_f_evals(&self) -> Index {
879        *self.grad_f_evals.borrow()
880    }
881    pub fn c_evals(&self) -> Index {
882        *self.c_evals.borrow()
883    }
884    pub fn d_evals(&self) -> Index {
885        *self.d_evals.borrow()
886    }
887    pub fn jac_c_evals(&self) -> Index {
888        *self.jac_c_evals.borrow()
889    }
890    pub fn jac_d_evals(&self) -> Index {
891        *self.jac_d_evals.borrow()
892    }
893    pub fn h_evals(&self) -> Index {
894        *self.h_evals.borrow()
895    }
896
897    /// Lift a compressed `x_var` (length `n_x_var`) up to the full TNLP
898    /// `x` (length `n_full_x`), inserting `x_fixed_vals` at the
899    /// `x_fixed_map` positions. Mirrors upstream
900    /// `IpTNLPAdapter::ResortX` under `fixed_variable_treatment =
901    /// make_parameter`.
902    pub fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
903        let Some(dx) = x.as_any().downcast_ref::<DenseVector>() else {
904            panic!("OrigIpoptNlp expects DenseVector for x");
905        };
906        let a = self.adapter.borrow();
907        let cls = a.classification();
908        let mut full = vec![0.0; cls.n_full_x as usize];
909        let vals = dx.expanded_values();
910        for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
911            full[full_idx as usize] = vals[var_idx];
912        }
913        for (i, &full_idx) in cls.x_fixed_map.iter().enumerate() {
914            full[full_idx as usize] = cls.x_fixed_vals[i];
915        }
916        full
917    }
918
919    /// Lift the algorithm-side `(y_c, y_d)` multipliers to the user
920    /// TNLP's `lambda` array (length `m_full = n_c + n_d`, indexed
921    /// by original constraint-row order). Result matches the user's
922    /// **unscaled-Lagrangian** convention `min f + λ·g(x)` —
923    /// i.e. without the obj_factor that the algorithm threads through
924    /// `eval_h`. Mirror of upstream
925    /// `IpOrigIpoptNLP::FinalizeSolution`'s `mult_g` packing
926    /// (`lambda_user = c_scale * y_c / obj_scale_factor`,
927    /// `mu_user = d_scale * y_d / obj_scale_factor`). Used by
928    /// `application.rs::finalize_via_orig_nlp` to populate the
929    /// `Solution.lambda` slot — pounce#11.
930    pub fn finalize_solution_lambda(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
931        let cls = self.adapter.borrow().classification().clone();
932        let mut lambda = self.pack_lambda_for_user(y_c, y_d, &cls);
933        let obj_scal = self.obj_scale_factor.get();
934        if obj_scal != 0.0 && obj_scal != 1.0 {
935            let inv = 1.0 / obj_scal;
936            for v in lambda.iter_mut() {
937                *v *= inv;
938            }
939        }
940        lambda
941    }
942
943    /// Lift the algorithm-side compressed `z_l` (length `n_x_l`,
944    /// indexed via `x_l_map`) to the user's full-x bound multiplier
945    /// array (length `n_full_x`). Slots without a finite lower bound
946    /// — including fixed variables — are reported as `0.0`. Sign and
947    /// scale match upstream Ipopt: `z_l ≥ 0` for active lower
948    /// bounds, divided by `obj_scale_factor` so the user sees the
949    /// unscaled-Lagrangian dual.
950    pub fn finalize_solution_z_l(&self, z_l: &dyn Vector) -> Vec<Number> {
951        let cls = self.adapter.borrow().classification().clone();
952        let n_full_x = cls.n_full_x as usize;
953        let mut full_z_l = vec![0.0; n_full_x];
954        let n_x_l = self.x_l.dim() as usize;
955        if n_x_l == 0 {
956            return full_z_l;
957        }
958        let Some(dz) = z_l.as_any().downcast_ref::<DenseVector>() else {
959            panic!("OrigIpoptNlp::finalize_solution_z_l expects DenseVector");
960        };
961        let vals = dz.expanded_values();
962        let obj_scal = self.obj_scale_factor.get();
963        let inv = if obj_scal == 0.0 { 1.0 } else { 1.0 / obj_scal };
964        for i in 0..n_x_l {
965            let var_idx = cls.x_l_map[i] as usize;
966            let full_idx = cls.x_not_fixed_map[var_idx] as usize;
967            full_z_l[full_idx] = vals[i] * inv;
968        }
969        full_z_l
970    }
971
972    /// Mirror of [`Self::finalize_solution_z_l`] for the upper-bound
973    /// duals. Indexed via `x_u_map`.
974    pub fn finalize_solution_z_u(&self, z_u: &dyn Vector) -> Vec<Number> {
975        let cls = self.adapter.borrow().classification().clone();
976        let n_full_x = cls.n_full_x as usize;
977        let mut full_z_u = vec![0.0; n_full_x];
978        let n_x_u = self.x_u.dim() as usize;
979        if n_x_u == 0 {
980            return full_z_u;
981        }
982        let Some(dz) = z_u.as_any().downcast_ref::<DenseVector>() else {
983            panic!("OrigIpoptNlp::finalize_solution_z_u expects DenseVector");
984        };
985        let vals = dz.expanded_values();
986        let obj_scal = self.obj_scale_factor.get();
987        let inv = if obj_scal == 0.0 { 1.0 } else { 1.0 / obj_scal };
988        for i in 0..n_x_u {
989            let var_idx = cls.x_u_map[i] as usize;
990            let full_idx = cls.x_not_fixed_map[var_idx] as usize;
991            full_z_u[full_idx] = vals[i] * inv;
992        }
993        full_z_u
994    }
995
996    /// Clone the user-provided multipliers (already in the
997    /// algorithm's eq/ineq-split form) into a single `lambda` array of
998    /// length `m_full = n_c + n_d` ordered by original g-index. Used
999    /// by `eval_h` and `finalize_solution`.
1000    /// Pack the algorithm-side `(y_c, y_d)` multipliers into the user
1001    /// TNLP's `lambda` array (full-g indexed), applying c/d scale
1002    /// factors so the result is in the user's unscaled-constraint
1003    /// multiplier space (`lambda_user_i = c_scale_i * y_c_i`). Used
1004    /// when invoking the user's `eval_h`.
1005    pub fn pack_lambda_for_user(
1006        &self,
1007        y_c: &dyn Vector,
1008        y_d: &dyn Vector,
1009        cls: &BoundClassification,
1010    ) -> Vec<Number> {
1011        let mut lambda = vec![0.0; cls.n_full_g as usize];
1012        if cls.n_c > 0 {
1013            let Some(dy) = y_c.as_any().downcast_ref::<DenseVector>() else {
1014                panic!("OrigIpoptNlp expects DenseVector for y_c");
1015            };
1016            let vals = dy.expanded_values();
1017            let cs = self.c_scale.borrow();
1018            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1019                lambda[g_idx as usize] = match cs.as_ref() {
1020                    Some(v) => vals[i] * v[i],
1021                    None => vals[i],
1022                };
1023            }
1024        }
1025        if cls.n_d > 0 {
1026            let Some(dy) = y_d.as_any().downcast_ref::<DenseVector>() else {
1027                panic!("OrigIpoptNlp expects DenseVector for y_d");
1028            };
1029            let vals = dy.expanded_values();
1030            let ds = self.d_scale.borrow();
1031            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1032                lambda[g_idx as usize] = match ds.as_ref() {
1033                    Some(v) => vals[i] * v[i],
1034                    None => vals[i],
1035                };
1036            }
1037        }
1038        lambda
1039    }
1040
1041    // -------------------- Initialization --------------------
1042
1043    /// Fill the algorithm's iterate slots with the TNLP's starting
1044    /// point. Mirrors the second half of upstream
1045    /// `InitializeStructures`. The caller passes already-allocated
1046    /// `DenseVector`s in the right spaces; we set them in place.
1047    ///
1048    /// Returns the four `init_*` flags so the caller can decide
1049    /// whether to overwrite zeros with the user's guess.
1050    #[allow(clippy::too_many_arguments)]
1051    pub fn initialize_starting_point(
1052        &mut self,
1053        x: &mut DenseVector,
1054        init_x: bool,
1055        y_c: &mut DenseVector,
1056        init_y_c: bool,
1057        y_d: &mut DenseVector,
1058        init_y_d: bool,
1059        z_l: &mut DenseVector,
1060        init_z_l: bool,
1061        z_u: &mut DenseVector,
1062        init_z_u: bool,
1063    ) -> bool {
1064        let n_full_x = self.adapter.borrow().classification().n_full_x as usize;
1065        let n_full_g = self.adapter.borrow().classification().n_full_g as usize;
1066        let n_x_l = self.x_l.dim() as usize;
1067        let n_x_u = self.x_u.dim() as usize;
1068
1069        let mut full_x = vec![0.0; n_full_x];
1070        let mut full_z_l = vec![0.0; n_full_x];
1071        let mut full_z_u = vec![0.0; n_full_x];
1072        let mut full_lambda = vec![0.0; n_full_g];
1073
1074        let ok = {
1075            let a = self.adapter.borrow();
1076            let mut t = a.tnlp().borrow_mut();
1077            t.get_starting_point(StartingPoint {
1078                init_x,
1079                x: &mut full_x,
1080                init_z: init_z_l || init_z_u,
1081                z_l: &mut full_z_l,
1082                z_u: &mut full_z_u,
1083                init_lambda: init_y_c || init_y_d,
1084                lambda: &mut full_lambda,
1085            })
1086        };
1087        if !ok {
1088            return false;
1089        }
1090
1091        let cls = self.adapter.borrow().classification().clone();
1092        let obj_scal = self.obj_scale_factor.get();
1093        let c_scale = self.c_scale.borrow();
1094        let d_scale = self.d_scale.borrow();
1095
1096        // Compress full_x → x.
1097        if init_x {
1098            let xs = x.values_mut();
1099            for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1100                xs[var_idx] = full_x[full_idx as usize];
1101            }
1102        }
1103        // Compress full_lambda → y_c, y_d. Upstream
1104        // (`IpOrigIpoptNLP.cpp:407-429`) divides the user multiplier
1105        // by the constraint scale (`unapply_vector_scaling_*`) and
1106        // multiplies by obj_scal so that the algorithm-side y_c sees
1107        // `(obj_scal / c_scale) * lambda_user`.
1108        if init_y_c && cls.n_c > 0 {
1109            let yc = y_c.values_mut();
1110            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1111                let cs = c_scale.as_ref().map(|v| v[i]).unwrap_or(1.0);
1112                yc[i] = full_lambda[g_idx as usize] / cs * obj_scal;
1113            }
1114        }
1115        if init_y_d && cls.n_d > 0 {
1116            let yd = y_d.values_mut();
1117            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1118                let ds = d_scale.as_ref().map(|v| v[i]).unwrap_or(1.0);
1119                yd[i] = full_lambda[g_idx as usize] / ds * obj_scal;
1120            }
1121        }
1122        // Compress full_z_l, full_z_u → z_l, z_u, indexed via x_l_map / x_u_map.
1123        if init_z_l && n_x_l > 0 {
1124            let zl = z_l.values_mut();
1125            for (i, slot) in zl.iter_mut().enumerate().take(n_x_l) {
1126                let var_idx = cls.x_l_map[i] as usize;
1127                let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1128                *slot = full_z_l[full_idx] * obj_scal;
1129            }
1130        }
1131        if init_z_u && n_x_u > 0 {
1132            let zu = z_u.values_mut();
1133            for (i, slot) in zu.iter_mut().enumerate().take(n_x_u) {
1134                let var_idx = cls.x_u_map[i] as usize;
1135                let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1136                *slot = full_z_u[full_idx] * obj_scal;
1137            }
1138        }
1139        true
1140    }
1141
1142    // -------------------- Internal eval helpers --------------------
1143
1144    fn eval_f_internal(&self, x: &dyn Vector) -> Number {
1145        if let Some(v) = self.f_cache.borrow().get_1dep(x.as_tagged()) {
1146            return v;
1147        }
1148        *self.f_evals.borrow_mut() += 1;
1149        let full_x = self.lift_x_to_full(x);
1150        let unscaled = {
1151            let a = self.adapter.borrow();
1152            let mut t = a.tnlp().borrow_mut();
1153            // A failed user eval (domain error, e.g. log of a negative) is
1154            // upstream Ipopt's `Eval_Error`. Return NaN so the line search's
1155            // non-finite-trial path backtracks the step, rather than aborting
1156            // — and a panic cannot unwind across the C FFI boundary anyway.
1157            t.eval_f(&full_x, true).unwrap_or(f64::NAN)
1158        };
1159        let scaled = unscaled * self.obj_scale_factor.get();
1160        self.f_cache.borrow_mut().add_1dep(scaled, x.as_tagged());
1161        scaled
1162    }
1163
1164    fn eval_grad_f_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1165        if let Some(v) = self.grad_f_cache.borrow().get_1dep(x.as_tagged()) {
1166            return v;
1167        }
1168        *self.grad_f_evals.borrow_mut() += 1;
1169        let full_x = self.lift_x_to_full(x);
1170        let mut full_g = vec![0.0; full_x.len()];
1171        let ok = {
1172            let a = self.adapter.borrow();
1173            let mut t = a.tnlp().borrow_mut();
1174            t.eval_grad_f(&full_x, true, &mut full_g)
1175        };
1176        // Eval failure → NaN-filled gradient, which propagates a non-finite
1177        // step the line search rejects (see `eval_f_internal`).
1178        if !ok {
1179            full_g.fill(f64::NAN);
1180        }
1181        // Compress full_g → grad in x_var-space, scale by obj_scal.
1182        let cls = self.adapter.borrow().classification().clone();
1183        let mut g_compressed = self.x_space.make_new_dense();
1184        let obj_scal = self.obj_scale_factor.get();
1185        {
1186            let gv = g_compressed.values_mut();
1187            for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1188                gv[var_idx] = full_g[full_idx as usize] * obj_scal;
1189            }
1190        }
1191        let result: Rc<dyn Vector> = Rc::new(g_compressed);
1192        self.grad_f_cache
1193            .borrow_mut()
1194            .add_1dep(Rc::clone(&result), x.as_tagged());
1195        result
1196    }
1197
1198    fn eval_c_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1199        let cls = self.adapter.borrow().classification().clone();
1200        if cls.n_c == 0 {
1201            // Empty constraint vector — still cache so the tag is stable.
1202            if let Some(v) = self.c_cache.borrow().get_1dep(x.as_tagged()) {
1203                return v;
1204            }
1205            let v = self.c_space.make_new_dense();
1206            let result: Rc<dyn Vector> = Rc::new(v);
1207            self.c_cache
1208                .borrow_mut()
1209                .add_1dep(Rc::clone(&result), x.as_tagged());
1210            return result;
1211        }
1212        if let Some(v) = self.c_cache.borrow().get_1dep(x.as_tagged()) {
1213            return v;
1214        }
1215        *self.c_evals.borrow_mut() += 1;
1216        let full_x = self.lift_x_to_full(x);
1217        let mut full_g = vec![0.0; cls.n_full_g as usize];
1218        let ok = {
1219            let a = self.adapter.borrow();
1220            let mut t = a.tnlp().borrow_mut();
1221            t.eval_g(&full_x, true, &mut full_g)
1222        };
1223        // Eval failure → NaN constraint values, so `theta_trial` goes
1224        // non-finite and the line search backtracks (see `eval_f_internal`).
1225        if !ok {
1226            full_g.fill(f64::NAN);
1227        }
1228        let mut c = self.c_space.make_new_dense();
1229        // c_i = g(g_idx) - g_l(g_idx)  (since g_l == g_u for equalities,
1230        // upstream subtracts the bound to make it a residual). Matches
1231        // `OrigIpoptNLP::c` which calls `nlp_->Eval_c` after the adapter
1232        // subtracted the bound — TNLPAdapter doesn't subtract yet, so we
1233        // do it here.
1234        let n_full_g = cls.n_full_g as usize;
1235        let mut full_g_l = vec![0.0; n_full_g];
1236        let mut full_g_u = vec![0.0; n_full_g];
1237        {
1238            let mut tmp_x_l = vec![0.0; cls.n_full_x as usize];
1239            let mut tmp_x_u = vec![0.0; cls.n_full_x as usize];
1240            let a = self.adapter.borrow();
1241            let mut t = a.tnlp().borrow_mut();
1242            t.get_bounds_info(crate::tnlp::BoundsInfo {
1243                x_l: &mut tmp_x_l,
1244                x_u: &mut tmp_x_u,
1245                g_l: &mut full_g_l,
1246                g_u: &mut full_g_u,
1247            });
1248        }
1249        {
1250            let cv = c.values_mut();
1251            let cs = self.c_scale.borrow();
1252            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1253                let raw = full_g[g_idx as usize] - full_g_l[g_idx as usize];
1254                cv[i] = match cs.as_ref() {
1255                    Some(v) => raw * v[i],
1256                    None => raw,
1257                };
1258            }
1259        }
1260        let result: Rc<dyn Vector> = Rc::new(c);
1261        self.c_cache
1262            .borrow_mut()
1263            .add_1dep(Rc::clone(&result), x.as_tagged());
1264        result
1265    }
1266
1267    fn eval_d_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1268        let cls = self.adapter.borrow().classification().clone();
1269        if cls.n_d == 0 {
1270            if let Some(v) = self.d_cache.borrow().get_1dep(x.as_tagged()) {
1271                return v;
1272            }
1273            let v = self.d_space.make_new_dense();
1274            let result: Rc<dyn Vector> = Rc::new(v);
1275            self.d_cache
1276                .borrow_mut()
1277                .add_1dep(Rc::clone(&result), x.as_tagged());
1278            return result;
1279        }
1280        if let Some(v) = self.d_cache.borrow().get_1dep(x.as_tagged()) {
1281            return v;
1282        }
1283        *self.d_evals.borrow_mut() += 1;
1284        let full_x = self.lift_x_to_full(x);
1285        let mut full_g = vec![0.0; cls.n_full_g as usize];
1286        let ok = {
1287            let a = self.adapter.borrow();
1288            let mut t = a.tnlp().borrow_mut();
1289            t.eval_g(&full_x, true, &mut full_g)
1290        };
1291        if !ok {
1292            full_g.fill(f64::NAN);
1293        }
1294        let mut d = self.d_space.make_new_dense();
1295        {
1296            let dv = d.values_mut();
1297            let ds = self.d_scale.borrow();
1298            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1299                let raw = full_g[g_idx as usize];
1300                dv[i] = match ds.as_ref() {
1301                    Some(v) => raw * v[i],
1302                    None => raw,
1303                };
1304            }
1305        }
1306        let result: Rc<dyn Vector> = Rc::new(d);
1307        self.d_cache
1308            .borrow_mut()
1309            .add_1dep(Rc::clone(&result), x.as_tagged());
1310        result
1311    }
1312
1313    fn eval_jac_c_internal(&self, x: &dyn Vector) -> Rc<dyn Matrix> {
1314        if let Some(m) = self.jac_c_cache.borrow().get_1dep(x.as_tagged()) {
1315            return m;
1316        }
1317        *self.jac_c_evals.borrow_mut() += 1;
1318        let mut full_vals = vec![0.0; self.nnz_jac_g_full as usize];
1319        let full_x = self.lift_x_to_full(x);
1320        let ok = {
1321            let a = self.adapter.borrow();
1322            let mut t = a.tnlp().borrow_mut();
1323            t.eval_jac_g(
1324                Some(&full_x),
1325                true,
1326                SparsityRequest::Values {
1327                    values: &mut full_vals,
1328                },
1329            )
1330        };
1331        if !ok {
1332            full_vals.fill(f64::NAN);
1333        }
1334        let mut jac_c = GenTMatrix::new(Rc::clone(&self.jac_c_space));
1335        {
1336            let cs = self.c_scale.borrow();
1337            let irows = self.jac_c_space.irows().to_vec();
1338            let vs = jac_c.values_mut();
1339            for (k, &src) in self.jac_c_entry_in_g.iter().enumerate() {
1340                let raw = full_vals[src as usize];
1341                vs[k] = match cs.as_ref() {
1342                    // irows are 1-based.
1343                    Some(v) => raw * v[(irows[k] - 1) as usize],
1344                    None => raw,
1345                };
1346            }
1347        }
1348        let result: Rc<dyn Matrix> = Rc::new(jac_c);
1349        self.jac_c_cache
1350            .borrow_mut()
1351            .add_1dep(Rc::clone(&result), x.as_tagged());
1352        result
1353    }
1354
1355    fn eval_jac_d_internal(&self, x: &dyn Vector) -> Rc<dyn Matrix> {
1356        if let Some(m) = self.jac_d_cache.borrow().get_1dep(x.as_tagged()) {
1357            return m;
1358        }
1359        *self.jac_d_evals.borrow_mut() += 1;
1360        let mut full_vals = vec![0.0; self.nnz_jac_g_full as usize];
1361        let full_x = self.lift_x_to_full(x);
1362        let ok = {
1363            let a = self.adapter.borrow();
1364            let mut t = a.tnlp().borrow_mut();
1365            t.eval_jac_g(
1366                Some(&full_x),
1367                true,
1368                SparsityRequest::Values {
1369                    values: &mut full_vals,
1370                },
1371            )
1372        };
1373        if !ok {
1374            full_vals.fill(f64::NAN);
1375        }
1376        let mut jac_d = GenTMatrix::new(Rc::clone(&self.jac_d_space));
1377        {
1378            let ds = self.d_scale.borrow();
1379            let irows = self.jac_d_space.irows().to_vec();
1380            let vs = jac_d.values_mut();
1381            for (k, &src) in self.jac_d_entry_in_g.iter().enumerate() {
1382                let raw = full_vals[src as usize];
1383                vs[k] = match ds.as_ref() {
1384                    Some(v) => raw * v[(irows[k] - 1) as usize],
1385                    None => raw,
1386                };
1387            }
1388        }
1389        let result: Rc<dyn Matrix> = Rc::new(jac_d);
1390        self.jac_d_cache
1391            .borrow_mut()
1392            .add_1dep(Rc::clone(&result), x.as_tagged());
1393        result
1394    }
1395
1396    fn eval_h_internal(
1397        &self,
1398        x: &dyn Vector,
1399        obj_factor: Number,
1400        y_c: &dyn Vector,
1401        y_d: &dyn Vector,
1402    ) -> Rc<dyn SymMatrix> {
1403        // h_cache key: (x, y_c, y_d) tags + obj_factor scalar dep, as
1404        // upstream `IpOrigIpoptNLP.cpp:786`.
1405        if let Some(m) = self.h_cache.borrow().get(
1406            &[x.as_tagged(), y_c.as_tagged(), y_d.as_tagged()],
1407            &[obj_factor],
1408        ) {
1409            return m;
1410        }
1411        *self.h_evals.borrow_mut() += 1;
1412        let Some(h_space) = self.h_space.as_ref() else {
1413            panic!(
1414                "OrigIpoptNlp::eval_h called but the TNLP did not provide \
1415                 eval_h sparsity. The L-BFGS path lands in Phase 8."
1416            );
1417        };
1418        let cls = self.adapter.borrow().classification().clone();
1419        let full_x = self.lift_x_to_full(x);
1420        // Upstream `IpOrigIpoptNLP.cpp:792-794` passes the user TNLP's
1421        // `eval_h` the multipliers in the user's unscaled-constraint
1422        // space, i.e. `lambda_user = c_scale * y_c` (and same for d).
1423        // The obj_factor is also scaled (`scaled_obj_factor = obj_scale
1424        // * obj_factor`). Together this gives the user-space Hessian
1425        // contribution that's already in the algorithm's scaled space
1426        // (no extra Hessian-side scaling because we don't scale x).
1427        let full_lambda = self.pack_lambda_for_user(y_c, y_d, &cls);
1428        let scaled_obj_factor = obj_factor * self.obj_scale_factor.get();
1429
1430        // The user TNLP writes `nnz_h_lag_full` values; the kept
1431        // (var-x ⊗ var-x) subset has `h_space.nonzeros()` entries
1432        // selected via `h_entry_in_full`. They differ when fixed
1433        // variables drop entries.
1434        let mut full_vals = vec![0.0; self.nnz_h_lag_full as usize];
1435        let ok = {
1436            let a = self.adapter.borrow();
1437            let mut t = a.tnlp().borrow_mut();
1438            t.eval_h(
1439                Some(&full_x),
1440                true,
1441                scaled_obj_factor,
1442                Some(&full_lambda),
1443                true,
1444                SparsityRequest::Values {
1445                    values: &mut full_vals,
1446                },
1447            )
1448        };
1449        if !ok {
1450            full_vals.fill(f64::NAN);
1451        }
1452        let mut h = SymTMatrix::new(Rc::clone(h_space));
1453        let kept = h_space.nonzeros() as usize;
1454        let h_vals = h.values_mut();
1455        // `h_entry_in_full` always has length `kept` (identity when no
1456        // fixed-var filtering, sparse selection otherwise).
1457        debug_assert_eq!(kept, self.h_entry_in_full.len());
1458        for (k, &src) in self.h_entry_in_full.iter().enumerate() {
1459            h_vals[k] = full_vals[src as usize];
1460        }
1461        let result: Rc<dyn SymMatrix> = Rc::new(h);
1462        self.h_cache.borrow_mut().add(
1463            Rc::clone(&result),
1464            &[x.as_tagged(), y_c.as_tagged(), y_d.as_tagged()],
1465            &[obj_factor],
1466        );
1467        result
1468    }
1469}
1470
1471// ---- helpers ----
1472
1473fn make_dense_from(
1474    space: &Rc<DenseVectorSpace>,
1475    mut f: impl FnMut(usize) -> Number,
1476) -> DenseVector {
1477    let mut v = space.make_new_dense();
1478    let dim = space.dim() as usize;
1479    if dim > 0 {
1480        let vs = v.values_mut();
1481        for (i, slot) in vs.iter_mut().enumerate().take(dim) {
1482            *slot = f(i);
1483        }
1484    }
1485    v
1486}
1487
1488// -------------------- Trait impls --------------------
1489
1490impl Nlp for OrigIpoptNlp {
1491    fn n(&self) -> Index {
1492        self.x_space.dim()
1493    }
1494    fn m_eq(&self) -> Index {
1495        self.c_space.dim()
1496    }
1497    fn m_ineq(&self) -> Index {
1498        self.d_space.dim()
1499    }
1500
1501    fn eval_f(&mut self, x: &dyn Vector) -> Number {
1502        self.timed_eval(|t| &t.eval_obj, || self.eval_f_internal(x))
1503    }
1504    fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1505        let result = self.timed_eval(|t| &t.eval_grad_obj, || self.eval_grad_f_internal(x));
1506        g.copy(&*result);
1507    }
1508    fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector) {
1509        let result = self.timed_eval(|t| &t.eval_constr, || self.eval_c_internal(x));
1510        c.copy(&*result);
1511    }
1512    fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector) {
1513        let result = self.timed_eval(|t| &t.eval_constr, || self.eval_d_internal(x));
1514        d.copy(&*result);
1515    }
1516    fn eval_jac_c(&mut self, x: &dyn Vector) -> Rc<dyn Matrix> {
1517        self.timed_eval(|t| &t.eval_constr_jac, || self.eval_jac_c_internal(x))
1518    }
1519    fn eval_jac_d(&mut self, x: &dyn Vector) -> Rc<dyn Matrix> {
1520        self.timed_eval(|t| &t.eval_constr_jac, || self.eval_jac_d_internal(x))
1521    }
1522    fn eval_h(
1523        &mut self,
1524        x: &dyn Vector,
1525        obj_factor: Number,
1526        y_c: &dyn Vector,
1527        y_d: &dyn Vector,
1528    ) -> Rc<dyn SymMatrix> {
1529        self.timed_eval(
1530            |t| &t.eval_lag_hess,
1531            || self.eval_h_internal(x, obj_factor, y_c, y_d),
1532        )
1533    }
1534}
1535
1536impl IpoptNlp for OrigIpoptNlp {
1537    fn x_l(&self) -> &dyn Vector {
1538        &*self.x_l
1539    }
1540    fn x_u(&self) -> &dyn Vector {
1541        &*self.x_u
1542    }
1543    fn d_l(&self) -> &dyn Vector {
1544        &*self.d_l
1545    }
1546    fn d_u(&self) -> &dyn Vector {
1547        &*self.d_u
1548    }
1549    fn px_l(&self) -> Rc<dyn Matrix> {
1550        Rc::clone(&self.px_l)
1551    }
1552    fn px_u(&self) -> Rc<dyn Matrix> {
1553        Rc::clone(&self.px_u)
1554    }
1555    fn pd_l(&self) -> Rc<dyn Matrix> {
1556        Rc::clone(&self.pd_l)
1557    }
1558    fn pd_u(&self) -> Rc<dyn Matrix> {
1559        Rc::clone(&self.pd_u)
1560    }
1561
1562    fn obj_scaling_factor(&self) -> Number {
1563        self.obj_scale_factor.get()
1564    }
1565
1566    /// Populate `x` (length `n_x_var`) from the TNLP's starting point,
1567    /// compressed via `x_not_fixed_map`. Mirrors the `init_x` arm of
1568    /// upstream `IpOrigIpoptNLP::GetStartingPoint`.
1569    fn get_starting_x(&mut self, x: &mut dyn Vector) -> bool {
1570        let cls = self.adapter.borrow().classification().clone();
1571        let n_full_x = cls.n_full_x as usize;
1572        let n_full_g = cls.n_full_g as usize;
1573        let mut full_x = vec![0.0; n_full_x];
1574        let mut full_z_l = vec![0.0; n_full_x];
1575        let mut full_z_u = vec![0.0; n_full_x];
1576        let mut full_lambda = vec![0.0; n_full_g];
1577        let ok = {
1578            let a = self.adapter.borrow();
1579            let mut t = a.tnlp().borrow_mut();
1580            t.get_starting_point(StartingPoint {
1581                init_x: true,
1582                x: &mut full_x,
1583                init_z: false,
1584                z_l: &mut full_z_l,
1585                z_u: &mut full_z_u,
1586                init_lambda: false,
1587                lambda: &mut full_lambda,
1588            })
1589        };
1590        if !ok {
1591            return false;
1592        }
1593        let Some(dx) = x.as_any_mut().downcast_mut::<DenseVector>() else {
1594            return false;
1595        };
1596        let xs = dx.values_mut();
1597        for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1598            xs[var_idx] = full_x[full_idx as usize];
1599        }
1600        true
1601    }
1602
1603    fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
1604        OrigIpoptNlp::lift_x_to_full(self, x)
1605    }
1606
1607    fn n_full_x(&self) -> Index {
1608        self.adapter.borrow().classification().n_full_x
1609    }
1610
1611    fn n_full_g(&self) -> Index {
1612        self.adapter.borrow().classification().n_full_g
1613    }
1614
1615    fn pack_lambda_for_user(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
1616        let cls = self.adapter.borrow().classification().clone();
1617        OrigIpoptNlp::pack_lambda_for_user(self, y_c, y_d, &cls)
1618    }
1619
1620    fn pack_g_for_user(&self, c: &dyn Vector, d: &dyn Vector) -> Vec<Number> {
1621        let cls = self.adapter.borrow().classification().clone();
1622        let mut g = vec![0.0; cls.n_full_g as usize];
1623        if cls.n_c > 0 {
1624            let Some(dc) = c.as_any().downcast_ref::<DenseVector>() else {
1625                panic!("OrigIpoptNlp expects DenseVector for c");
1626            };
1627            let cs = self.c_scale.borrow();
1628            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1629                let v = dc.expanded_values()[i];
1630                g[g_idx as usize] = match cs.as_ref() {
1631                    Some(s) => v / s[i],
1632                    None => v,
1633                };
1634            }
1635        }
1636        if cls.n_d > 0 {
1637            let Some(dd) = d.as_any().downcast_ref::<DenseVector>() else {
1638                panic!("OrigIpoptNlp expects DenseVector for d");
1639            };
1640            let ds = self.d_scale.borrow();
1641            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1642                let v = dd.expanded_values()[i];
1643                g[g_idx as usize] = match ds.as_ref() {
1644                    Some(s) => v / s[i],
1645                    None => v,
1646                };
1647            }
1648        }
1649        g
1650    }
1651
1652    fn pack_z_l_for_user(&self, z_l: &dyn Vector) -> Vec<Number> {
1653        let cls = self.adapter.borrow().classification().clone();
1654        let mut full = vec![0.0; cls.n_full_x as usize];
1655        if z_l.dim() == 0 {
1656            return full;
1657        }
1658        let Some(dz) = z_l.as_any().downcast_ref::<DenseVector>() else {
1659            panic!("OrigIpoptNlp expects DenseVector for z_l");
1660        };
1661        let vals = dz.expanded_values();
1662        for (k, &var_idx) in cls.x_l_map.iter().enumerate() {
1663            let full_idx = cls.x_not_fixed_map[var_idx as usize] as usize;
1664            full[full_idx] = vals[k];
1665        }
1666        full
1667    }
1668
1669    fn pack_z_u_for_user(&self, z_u: &dyn Vector) -> Vec<Number> {
1670        let cls = self.adapter.borrow().classification().clone();
1671        let mut full = vec![0.0; cls.n_full_x as usize];
1672        if z_u.dim() == 0 {
1673            return full;
1674        }
1675        let Some(dz) = z_u.as_any().downcast_ref::<DenseVector>() else {
1676            panic!("OrigIpoptNlp expects DenseVector for z_u");
1677        };
1678        let vals = dz.expanded_values();
1679        for (k, &var_idx) in cls.x_u_map.iter().enumerate() {
1680            let full_idx = cls.x_not_fixed_map[var_idx as usize] as usize;
1681            full[full_idx] = vals[k];
1682        }
1683        full
1684    }
1685
1686    fn finalize_solution_lambda(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
1687        OrigIpoptNlp::finalize_solution_lambda(self, y_c, y_d)
1688    }
1689
1690    fn finalize_solution_z_l(&self, z_l: &dyn Vector) -> Vec<Number> {
1691        OrigIpoptNlp::finalize_solution_z_l(self, z_l)
1692    }
1693
1694    fn finalize_solution_z_u(&self, z_u: &dyn Vector) -> Vec<Number> {
1695        OrigIpoptNlp::finalize_solution_z_u(self, z_u)
1696    }
1697
1698    fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
1699        let cls = self.adapter.borrow();
1700        let cls = cls.classification();
1701        let f = full_idx as usize;
1702        if f >= cls.full_to_var.len() {
1703            return None;
1704        }
1705        let v = cls.full_to_var[f];
1706        if v < 0 {
1707            None
1708        } else {
1709            Some(v)
1710        }
1711    }
1712
1713    fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
1714        let cls = self.adapter.borrow();
1715        let cls = cls.classification();
1716        cls.c_map
1717            .iter()
1718            .position(|&g_idx| g_idx == full_idx)
1719            .map(|p| p as Index)
1720    }
1721
1722    fn var_x_to_full_x(&self, var_idx: Index) -> Index {
1723        let cls = self.adapter.borrow();
1724        let cls = cls.classification();
1725        cls.x_not_fixed_map[var_idx as usize]
1726    }
1727}
1728
1729// -------------------- Tests --------------------
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734    use crate::tnlp::{
1735        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
1736        StartingPoint, TNLP,
1737    };
1738
1739    /// HS071: min x[0]*x[3]*(x[0]+x[1]+x[2]) + x[2]
1740    /// s.t.   x[0]*x[1]*x[2]*x[3] >= 25                (inequality)
1741    ///        x[0]^2 + x[1]^2 + x[2]^2 + x[3]^2 == 40  (equality)
1742    ///        1 <= x[i] <= 5
1743    #[derive(Default)]
1744    struct Hs071 {
1745        eval_f_calls: usize,
1746        eval_grad_f_calls: usize,
1747        eval_g_calls: usize,
1748        eval_jac_g_value_calls: usize,
1749        eval_h_value_calls: usize,
1750    }
1751
1752    impl TNLP for Hs071 {
1753        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1754            Some(NlpInfo {
1755                n: 4,
1756                m: 2,
1757                nnz_jac_g: 8,
1758                nnz_h_lag: 10,
1759                index_style: IndexStyle::C,
1760            })
1761        }
1762        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1763            b.x_l.copy_from_slice(&[1.0; 4]);
1764            b.x_u.copy_from_slice(&[5.0; 4]);
1765            // Constraint 0: 25 <= g0 (inequality, finite lower only)
1766            // Constraint 1: g1 == 40                (equality)
1767            b.g_l.copy_from_slice(&[25.0, 40.0]);
1768            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
1769            true
1770        }
1771        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1772            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
1773            true
1774        }
1775        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1776            self.eval_f_calls += 1;
1777            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
1778        }
1779        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1780            self.eval_grad_f_calls += 1;
1781            // df/dx0 = x3*(2x0 + x1 + x2)
1782            // df/dx1 = x0*x3
1783            // df/dx2 = x0*x3 + 1
1784            // df/dx3 = x0*(x0 + x1 + x2)
1785            g[0] = x[3] * (2.0 * x[0] + x[1] + x[2]);
1786            g[1] = x[0] * x[3];
1787            g[2] = x[0] * x[3] + 1.0;
1788            g[3] = x[0] * (x[0] + x[1] + x[2]);
1789            true
1790        }
1791        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1792            self.eval_g_calls += 1;
1793            // g0 = x0*x1*x2*x3 (>=25)
1794            // g1 = x0^2 + x1^2 + x2^2 + x3^2 (==40)
1795            g[0] = x[0] * x[1] * x[2] * x[3];
1796            g[1] = x[0] * x[0] + x[1] * x[1] + x[2] * x[2] + x[3] * x[3];
1797            true
1798        }
1799        fn eval_jac_g(
1800            &mut self,
1801            x: Option<&[Number]>,
1802            _new_x: bool,
1803            mode: SparsityRequest<'_>,
1804        ) -> bool {
1805            match mode {
1806                SparsityRequest::Structure { irow, jcol } => {
1807                    // Dense 2x4: row-major (g0 over x0..x3, then g1 over x0..x3).
1808                    irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
1809                    jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
1810                }
1811                SparsityRequest::Values { values } => {
1812                    self.eval_jac_g_value_calls += 1;
1813                    let x = x.expect("eval_jac_g(Values) without x");
1814                    // d g0 / d x_j
1815                    values[0] = x[1] * x[2] * x[3];
1816                    values[1] = x[0] * x[2] * x[3];
1817                    values[2] = x[0] * x[1] * x[3];
1818                    values[3] = x[0] * x[1] * x[2];
1819                    // d g1 / d x_j
1820                    values[4] = 2.0 * x[0];
1821                    values[5] = 2.0 * x[1];
1822                    values[6] = 2.0 * x[2];
1823                    values[7] = 2.0 * x[3];
1824                }
1825            }
1826            true
1827        }
1828        fn eval_h(
1829            &mut self,
1830            x: Option<&[Number]>,
1831            _new_x: bool,
1832            obj_factor: Number,
1833            lambda: Option<&[Number]>,
1834            _new_lambda: bool,
1835            mode: SparsityRequest<'_>,
1836        ) -> bool {
1837            // Dense lower triangle of 4x4 = 10 entries:
1838            // (0,0) (1,0) (1,1) (2,0) (2,1) (2,2) (3,0) (3,1) (3,2) (3,3)
1839            match mode {
1840                SparsityRequest::Structure { irow, jcol } => {
1841                    irow.copy_from_slice(&[0, 1, 1, 2, 2, 2, 3, 3, 3, 3]);
1842                    jcol.copy_from_slice(&[0, 0, 1, 0, 1, 2, 0, 1, 2, 3]);
1843                }
1844                SparsityRequest::Values { values } => {
1845                    self.eval_h_value_calls += 1;
1846                    let x = x.expect("eval_h(Values) without x");
1847                    let lam = lambda.expect("eval_h(Values) without lambda");
1848                    let of = obj_factor;
1849                    // Hessian of objective:
1850                    //   d2f/dx0^2 = 2*x3
1851                    //   d2f/dx0dx1 = x3,  d2f/dx0dx2 = x3,
1852                    //   d2f/dx0dx3 = 2*x0+x1+x2
1853                    //   d2f/dx1dx3 = x0,  d2f/dx2dx3 = x0
1854                    // Hessian of g0 = x0*x1*x2*x3:
1855                    //   d2/dx0dx1 = x2*x3, d2/dx0dx2 = x1*x3, d2/dx0dx3 = x1*x2
1856                    //   d2/dx1dx2 = x0*x3, d2/dx1dx3 = x0*x2, d2/dx2dx3 = x0*x1
1857                    // Hessian of g1 = sum x_i^2: 2*I.
1858                    let l0 = lam[0];
1859                    let l1 = lam[1];
1860                    values[0] = of * (2.0 * x[3]) + l1 * 2.0; // (0,0)
1861                    values[1] = of * x[3] + l0 * (x[2] * x[3]); // (1,0)
1862                    values[2] = l1 * 2.0; // (1,1)
1863                    values[3] = of * x[3] + l0 * (x[1] * x[3]); // (2,0)
1864                    values[4] = l0 * (x[0] * x[3]); // (2,1)
1865                    values[5] = l1 * 2.0; // (2,2)
1866                    values[6] = of * (2.0 * x[0] + x[1] + x[2]) + l0 * (x[1] * x[2]); // (3,0)
1867                    values[7] = of * x[0] + l0 * (x[0] * x[2]); // (3,1)
1868                    values[8] = of * x[0] + l0 * (x[0] * x[1]); // (3,2)
1869                    values[9] = l1 * 2.0; // (3,3)
1870                }
1871            }
1872            true
1873        }
1874        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
1875    }
1876
1877    fn build_orig_nlp() -> (Rc<RefCell<TNLPAdapter>>, OrigIpoptNlp) {
1878        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Hs071::default()));
1879        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
1880        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
1881        (adapter, nlp)
1882    }
1883
1884    fn dense_x(values: &[Number], space: &Rc<DenseVectorSpace>) -> DenseVector {
1885        let mut v = space.make_new_dense();
1886        v.values_mut().copy_from_slice(values);
1887        v
1888    }
1889
1890    #[test]
1891    fn dimensions_match_classification() {
1892        let (_, nlp) = build_orig_nlp();
1893        // HS071: 4 vars (none fixed), 1 equality, 1 inequality.
1894        assert_eq!(nlp.n(), 4);
1895        assert_eq!(nlp.m_eq(), 1);
1896        assert_eq!(nlp.m_ineq(), 1);
1897        // 4 entries of jac_g go to c-row (g1), 4 go to d-row (g0).
1898        assert_eq!(nlp.jac_c_space().nonzeros(), 4);
1899        assert_eq!(nlp.jac_d_space().nonzeros(), 4);
1900        // Hessian sparsity comes through.
1901        assert_eq!(nlp.h_space().unwrap().nonzeros(), 10);
1902        // Bounds: all 4 x's bounded both sides; 1 ineq with finite lower only.
1903        assert_eq!(nlp.x_l().dim(), 4);
1904        assert_eq!(nlp.x_u().dim(), 4);
1905        assert_eq!(nlp.d_l().dim(), 1);
1906        assert_eq!(nlp.d_u().dim(), 0);
1907    }
1908
1909    #[test]
1910    fn eval_f_at_starting_point() {
1911        let (_, mut nlp) = build_orig_nlp();
1912        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1913        // f = 1*1*(1+5+5) + 5 = 11 + 5 = 16
1914        assert_eq!(nlp.eval_f(&x), 16.0);
1915        assert_eq!(nlp.f_evals(), 1);
1916    }
1917
1918    #[test]
1919    fn eval_grad_f_at_starting_point() {
1920        let (_, mut nlp) = build_orig_nlp();
1921        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1922        let mut g = nlp.x_space().make_new_dense();
1923        nlp.eval_grad_f(&x, &mut g);
1924        // df/dx0 = 1*(2 + 5 + 5) = 12
1925        // df/dx1 = 1*1 = 1
1926        // df/dx2 = 1*1 + 1 = 2
1927        // df/dx3 = 1*(1 + 5 + 5) = 11
1928        assert_eq!(g.values(), &[12.0, 1.0, 2.0, 11.0]);
1929        assert_eq!(nlp.grad_f_evals(), 1);
1930    }
1931
1932    #[test]
1933    fn eval_c_returns_equality_residual() {
1934        let (_, mut nlp) = build_orig_nlp();
1935        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1936        let mut c = nlp.c_space().make_new_dense();
1937        nlp.eval_c(&x, &mut c);
1938        // g1 = 1 + 25 + 25 + 1 = 52; residual = 52 - 40 = 12.
1939        assert_eq!(c.values(), &[12.0]);
1940        assert_eq!(nlp.c_evals(), 1);
1941    }
1942
1943    #[test]
1944    fn eval_d_returns_inequality_value_unshifted() {
1945        let (_, mut nlp) = build_orig_nlp();
1946        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1947        let mut d = nlp.d_space().make_new_dense();
1948        nlp.eval_d(&x, &mut d);
1949        // g0 = 1*5*5*1 = 25.
1950        assert_eq!(d.values(), &[25.0]);
1951        assert_eq!(nlp.d_evals(), 1);
1952    }
1953
1954    #[test]
1955    fn cache_returns_without_re_eval() {
1956        let (_, mut nlp) = build_orig_nlp();
1957        let mut x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1958        let f1 = nlp.eval_f(&x);
1959        let f2 = nlp.eval_f(&x);
1960        assert_eq!(f1, f2);
1961        assert_eq!(nlp.f_evals(), 1, "second call must be served from cache");
1962        // Bumping x's tag (i.e. mutating it) should invalidate the cache.
1963        x.values_mut()[0] = 1.0; // values_mut bumps the cache.
1964        let _ = nlp.eval_f(&x);
1965        assert_eq!(nlp.f_evals(), 2);
1966    }
1967
1968    #[test]
1969    fn jac_c_picks_only_equality_rows() {
1970        let (_, mut nlp) = build_orig_nlp();
1971        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1972        let m = nlp.eval_jac_c(&x);
1973        let g = m
1974            .as_any()
1975            .downcast_ref::<GenTMatrix>()
1976            .expect("jac_c is a GenTMatrix");
1977        // Equality is g1: dg1/dxj = 2*x_j.
1978        assert_eq!(g.values(), &[2.0, 10.0, 10.0, 2.0]);
1979        // 1-based row should all be 1 (the single equality row).
1980        assert_eq!(g.irows(), &[1, 1, 1, 1]);
1981        assert_eq!(g.jcols(), &[1, 2, 3, 4]);
1982    }
1983
1984    #[test]
1985    fn jac_d_picks_only_inequality_rows() {
1986        let (_, mut nlp) = build_orig_nlp();
1987        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
1988        let m = nlp.eval_jac_d(&x);
1989        let g = m
1990            .as_any()
1991            .downcast_ref::<GenTMatrix>()
1992            .expect("jac_d is a GenTMatrix");
1993        // Inequality is g0: d/dxj of x0*x1*x2*x3 at (1,5,5,1).
1994        // d/dx0 = 5*5*1 = 25, d/dx1 = 1*5*1 = 5, d/dx2 = 1*5*1 = 5, d/dx3 = 1*5*5 = 25.
1995        assert_eq!(g.values(), &[25.0, 5.0, 5.0, 25.0]);
1996    }
1997
1998    #[test]
1999    fn starting_point_is_compressed_into_x_var() {
2000        let (_, mut nlp) = build_orig_nlp();
2001        let mut x = nlp.x_space().make_new_dense();
2002        let mut yc = nlp.c_space().make_new_dense();
2003        let mut yd = nlp.d_space().make_new_dense();
2004        let mut zl = nlp.x_l_space().make_new_dense();
2005        let mut zu = nlp.x_u_space().make_new_dense();
2006        let ok = nlp.initialize_starting_point(
2007            &mut x, true, &mut yc, false, &mut yd, false, &mut zl, false, &mut zu, false,
2008        );
2009        assert!(ok);
2010        assert_eq!(x.values(), &[1.0, 5.0, 5.0, 1.0]);
2011    }
2012
2013    /// Two-variable TNLP with `x[0]` fixed at 7.0 (`x_l == x_u`) and
2014    /// one equality on `x[1]`. Exercises the index-mapping methods on
2015    /// the `IpoptNlp` trait that are used by `pounce_sens` to support
2016    /// `.nl` files with fixed variables.
2017    struct OneFixedOneFree;
2018    impl TNLP for OneFixedOneFree {
2019        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2020            Some(NlpInfo {
2021                n: 2,
2022                m: 1,
2023                nnz_jac_g: 1,
2024                nnz_h_lag: 0,
2025                index_style: IndexStyle::C,
2026            })
2027        }
2028        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2029            b.x_l[0] = 7.0;
2030            b.x_u[0] = 7.0; // fixed
2031            b.x_l[1] = -1.0e19;
2032            b.x_u[1] = 1.0e19;
2033            b.g_l[0] = 0.0;
2034            b.g_u[0] = 0.0; // equality
2035            true
2036        }
2037        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2038            sp.x[0] = 7.0;
2039            sp.x[1] = 0.5;
2040            true
2041        }
2042        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2043            Some(x[1])
2044        }
2045        fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
2046            g[0] = 0.0;
2047            g[1] = 1.0;
2048            true
2049        }
2050        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2051            g[0] = x[1];
2052            true
2053        }
2054        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2055            match m {
2056                SparsityRequest::Structure { irow, jcol } => {
2057                    irow[0] = 0;
2058                    jcol[0] = 1;
2059                }
2060                SparsityRequest::Values { values } => values[0] = 1.0,
2061            }
2062            true
2063        }
2064        fn eval_h(
2065            &mut self,
2066            _: Option<&[Number]>,
2067            _: bool,
2068            _: Number,
2069            _: Option<&[Number]>,
2070            _: bool,
2071            _: SparsityRequest<'_>,
2072        ) -> bool {
2073            true
2074        }
2075        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2076    }
2077
2078    #[test]
2079    fn ipopt_nlp_index_mapping_methods_handle_fixed_var() {
2080        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedOneFree));
2081        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2082        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2083
2084        // Sanity: classification trimmed x[0] (fixed) from var-x space.
2085        assert_eq!(nlp.n_full_x(), 2);
2086        assert_eq!(nlp.n(), 1);
2087
2088        // full_x_to_var_x: x[0] is fixed → None; x[1] → var idx 0.
2089        let nlp_dyn: &dyn crate::ipopt_nlp::IpoptNlp = &nlp;
2090        assert_eq!(nlp_dyn.full_x_to_var_x(0), None);
2091        assert_eq!(nlp_dyn.full_x_to_var_x(1), Some(0));
2092
2093        // var_x_to_full_x: var 0 → full 1.
2094        assert_eq!(nlp_dyn.var_x_to_full_x(0), 1);
2095
2096        // full_g_to_c_block: the one g is an equality → c-block 0.
2097        assert_eq!(nlp_dyn.full_g_to_c_block(0), Some(0));
2098
2099        // lift_x_to_full inflates a compressed [v_0] back to [7.0, v_0].
2100        let mut x_var = nlp.x_space().make_new_dense();
2101        x_var.values_mut()[0] = 0.5;
2102        let lifted = nlp_dyn.lift_x_to_full(&x_var);
2103        assert_eq!(lifted, vec![7.0, 0.5]);
2104    }
2105
2106    /// Regression: a TNLP with `x[0]` fixed and `nnz_h_lag = 1` whose
2107    /// only Hessian entry is (0,0). After fixed-var filtering kept = 0
2108    /// but `nnz_h_lag_full = 1`, which used to hit the broken
2109    /// `h_entry_in_full.is_empty()` fast path and panic in
2110    /// `copy_from_slice`.
2111    struct FixedOnlyHess;
2112    impl TNLP for FixedOnlyHess {
2113        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2114            Some(NlpInfo {
2115                n: 2,
2116                m: 1,
2117                nnz_jac_g: 1,
2118                nnz_h_lag: 1,
2119                index_style: IndexStyle::C,
2120            })
2121        }
2122        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2123            b.x_l[0] = 7.0;
2124            b.x_u[0] = 7.0; // fixed
2125            b.x_l[1] = -1.0e19;
2126            b.x_u[1] = 1.0e19;
2127            b.g_l[0] = 0.0;
2128            b.g_u[0] = 0.0;
2129            true
2130        }
2131        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2132            sp.x[0] = 7.0;
2133            sp.x[1] = 0.5;
2134            true
2135        }
2136        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2137            Some(0.5 * x[0] * x[0] + x[1])
2138        }
2139        fn eval_grad_f(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2140            g[0] = x[0];
2141            g[1] = 1.0;
2142            true
2143        }
2144        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2145            g[0] = x[1];
2146            true
2147        }
2148        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2149            match m {
2150                SparsityRequest::Structure { irow, jcol } => {
2151                    irow[0] = 0;
2152                    jcol[0] = 1;
2153                }
2154                SparsityRequest::Values { values } => values[0] = 1.0,
2155            }
2156            true
2157        }
2158        fn eval_h(
2159            &mut self,
2160            _: Option<&[Number]>,
2161            _: bool,
2162            obj_factor: Number,
2163            _: Option<&[Number]>,
2164            _: bool,
2165            m: SparsityRequest<'_>,
2166        ) -> bool {
2167            match m {
2168                SparsityRequest::Structure { irow, jcol } => {
2169                    irow[0] = 0;
2170                    jcol[0] = 0;
2171                }
2172                SparsityRequest::Values { values } => values[0] = obj_factor,
2173            }
2174            true
2175        }
2176        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2177    }
2178
2179    #[test]
2180    fn eval_h_with_all_entries_on_fixed_var_does_not_panic() {
2181        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FixedOnlyHess));
2182        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2183        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2184
2185        // After filtering, the kept Hessian over var-x has 0 nonzeros,
2186        // while the user's full Hessian has 1.
2187        assert_eq!(nlp.h_space().unwrap().nonzeros(), 0);
2188
2189        let x = dense_x(&[0.5], &nlp.x_space().clone());
2190        let yc = dense_x(&[0.0], &nlp.c_space().clone());
2191        let yd = nlp.d_space().make_new_dense();
2192        let h = nlp.eval_h(&x, 1.0, &yc, &yd);
2193        assert_eq!(h.n_rows(), 1);
2194    }
2195}