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, SplitNames};
61use crate::tnlp::{MetaData, NlpInfo, ScalingRequest, SparsityRequest, StartingPoint, IDX_NAMES};
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    /// User-supplied scaling via [`crate::tnlp::TNLP::get_scaling_parameters`].
109    /// Port of upstream's `nlp_scaling_method=user-scaling`. The TNLP
110    /// fills `obj_scaling` and the per-constraint `g_scaling`; the
111    /// per-variable `x_scaling` request is honored only insofar as
112    /// `OrigIpoptNlp` currently models constraint+objective scaling
113    /// (no variable-side rescale, matching the issue #61 design).
114    UserScaling,
115}
116
117/// Concrete `IpoptNlp` over a `TNLPAdapter`. Mirrors upstream
118/// `Ipopt::OrigIpoptNLP`.
119pub struct OrigIpoptNlp {
120    /// Backing TNLP (and its bound classification).
121    adapter: Rc<RefCell<TNLPAdapter>>,
122    /// Constant objective-scaling multiplier supplied by the user
123    /// (mirrors upstream's `obj_scaling_factor` option). The
124    /// gradient-based factor is multiplied into [`obj_scale_factor`]
125    /// after [`Self::determine_scaling_from_starting_point`] runs.
126    scaling: Rc<dyn NlpScaling>,
127
128    // ----- gradient-based scaling state (port of `IpGradientScaling.cpp`) -----
129    /// Effective objective scaling factor `df_` (1.0 when no scaling).
130    obj_scale_factor: Cell<Number>,
131    /// Per-row scaling for equality constraints (`dc_`). `None` ↔
132    /// `IsValid(dc) == false` — i.e. row-max gradient is below the
133    /// `nlp_scaling_max_gradient` cutoff so no scaling is applied.
134    c_scale: RefCell<Option<Vec<Number>>>,
135    /// Same as [`Self::c_scale`] but for inequality rows.
136    d_scale: RefCell<Option<Vec<Number>>>,
137
138    // ----- vector / matrix spaces (shared via Rc) -----
139    x_space: Rc<DenseVectorSpace>,
140    c_space: Rc<DenseVectorSpace>,
141    d_space: Rc<DenseVectorSpace>,
142    x_l_space: Rc<DenseVectorSpace>,
143    x_u_space: Rc<DenseVectorSpace>,
144    d_l_space: Rc<DenseVectorSpace>,
145    d_u_space: Rc<DenseVectorSpace>,
146    px_l_space: Rc<ExpansionMatrixSpace>,
147    px_u_space: Rc<ExpansionMatrixSpace>,
148    pd_l_space: Rc<ExpansionMatrixSpace>,
149    pd_u_space: Rc<ExpansionMatrixSpace>,
150    jac_c_space: Rc<GenTMatrixSpace>,
151    jac_d_space: Rc<GenTMatrixSpace>,
152    /// Hessian space; `None` when `eval_h` is not provided by the TNLP
153    /// (the limited-memory quasi-Newton path lands in Phase 8).
154    h_space: Option<Rc<SymTMatrixSpace>>,
155
156    // ----- bound vectors (compressed-x sub-spaces) -----
157    x_l: Rc<DenseVector>,
158    x_u: Rc<DenseVector>,
159    d_l: Rc<DenseVector>,
160    d_u: Rc<DenseVector>,
161
162    // ----- expansion matrices (instances; spaces above) -----
163    px_l: Rc<dyn Matrix>,
164    px_u: Rc<dyn Matrix>,
165    pd_l: Rc<dyn Matrix>,
166    pd_u: Rc<dyn Matrix>,
167
168    // ----- jacobian sparsity remap -----
169    /// `jac_c_entry_in_g[k]` = position in the full TNLP jacobian's
170    /// values array of the k-th equality-row entry.
171    jac_c_entry_in_g: Vec<Index>,
172    /// Same for inequality rows.
173    jac_d_entry_in_g: Vec<Index>,
174    /// Total nonzeros in the full (un-split) `eval_jac_g` triplet.
175    nnz_jac_g_full: Index,
176
177    // ----- hessian sparsity remap (fixed-var filtering) -----
178    /// Total nonzeros the user's `eval_h` writes into. May exceed
179    /// `h_space.nonzeros()` when fixed variables drop entries.
180    nnz_h_lag_full: Index,
181    /// `h_entry_in_full[k]` = position in the full TNLP hessian's
182    /// values array of the k-th kept entry. Always has length
183    /// `h_space.nonzeros()`; equals the identity `[0, 1, …, n-1]`
184    /// when no fixed-var filtering dropped any entries.
185    h_entry_in_full: Vec<Index>,
186
187    // ----- caches (one entry; key = input vector tag) -----
188    f_cache: RefCell<Cache<Number>>,
189    grad_f_cache: RefCell<Cache<Rc<dyn Vector>>>,
190    c_cache: RefCell<Cache<Rc<dyn Vector>>>,
191    d_cache: RefCell<Cache<Rc<dyn Vector>>>,
192    jac_c_cache: RefCell<Cache<Rc<dyn Matrix>>>,
193    jac_d_cache: RefCell<Cache<Rc<dyn Matrix>>>,
194    h_cache: RefCell<Cache<Rc<dyn SymMatrix>>>,
195
196    // ----- evaluation counters -----
197    f_evals: RefCell<Index>,
198    grad_f_evals: RefCell<Index>,
199    c_evals: RefCell<Index>,
200    d_evals: RefCell<Index>,
201    jac_c_evals: RefCell<Index>,
202    jac_d_evals: RefCell<Index>,
203    h_evals: RefCell<Index>,
204
205    /// Cached `NlpInfo` (n, m, nnz_jac_g, nnz_h_lag, index_style) so we
206    /// don't re-borrow the TNLP for dimension queries.
207    info: NlpInfo,
208
209    /// Shared per-subsystem timing accumulator. `None` until
210    /// `IpoptApplication` installs the shared `Rc<TimingStatistics>` via
211    /// [`Self::set_timing_stats`]; when `None`, all `eval_*` entry
212    /// points skip the timing overhead.
213    timing: RefCell<Option<Rc<TimingStatistics>>>,
214}
215
216impl std::fmt::Debug for OrigIpoptNlp {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("OrigIpoptNlp")
219            .field("info", &self.info)
220            .field("f_evals", &*self.f_evals.borrow())
221            .field("grad_f_evals", &*self.grad_f_evals.borrow())
222            .field("c_evals", &*self.c_evals.borrow())
223            .field("d_evals", &*self.d_evals.borrow())
224            .field("jac_c_evals", &*self.jac_c_evals.borrow())
225            .field("jac_d_evals", &*self.jac_d_evals.borrow())
226            .field("h_evals", &*self.h_evals.borrow())
227            .finish_non_exhaustive()
228    }
229}
230
231impl OrigIpoptNlp {
232    /// Construct an `OrigIpoptNlp` from a (already-classified) adapter
233    /// and a scaling object. Mirrors
234    /// `OrigIpoptNLP::OrigIpoptNLP` + `InitializeStructures`
235    /// (`IpOrigIpoptNLP.cpp:22-457`) — the parts that don't need an
236    /// `OptionsList` (those land with the option-machinery integration).
237    pub fn new(
238        adapter: Rc<RefCell<TNLPAdapter>>,
239        scaling: Rc<dyn NlpScaling>,
240    ) -> Result<Self, String> {
241        // Snapshot dimensions / classification from the adapter.
242        let (info, classification) = {
243            let a = adapter.borrow();
244            (*a.nlp_info(), a.classification().clone())
245        };
246
247        // ---- Vector spaces ----
248        let n_x_var = classification.n_x_var();
249        let x_space = DenseVectorSpace::new(n_x_var);
250        let c_space = DenseVectorSpace::new(classification.n_c);
251        let d_space = DenseVectorSpace::new(classification.n_d);
252        let x_l_space = DenseVectorSpace::new(classification.n_x_l());
253        let x_u_space = DenseVectorSpace::new(classification.n_x_u());
254        let d_l_space = DenseVectorSpace::new(classification.n_d_l());
255        let d_u_space = DenseVectorSpace::new(classification.n_d_u());
256
257        // ---- Expansion matrix spaces (column-compressed → full x_var / d) ----
258        let px_l_space =
259            ExpansionMatrixSpace::new(n_x_var, classification.n_x_l(), &classification.x_l_map, 0);
260        let px_u_space =
261            ExpansionMatrixSpace::new(n_x_var, classification.n_x_u(), &classification.x_u_map, 0);
262        let pd_l_space = ExpansionMatrixSpace::new(
263            classification.n_d,
264            classification.n_d_l(),
265            &classification.d_l_map,
266            0,
267        );
268        let pd_u_space = ExpansionMatrixSpace::new(
269            classification.n_d,
270            classification.n_d_u(),
271            &classification.d_u_map,
272            0,
273        );
274        let px_l: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&px_l_space)));
275        let px_u: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&px_u_space)));
276        let pd_l: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&pd_l_space)));
277        let pd_u: Rc<dyn Matrix> = Rc::new(ExpansionMatrix::new(Rc::clone(&pd_u_space)));
278
279        // ---- Bound vectors. Pull the full `x_l/x_u/g_l/g_u` arrays from
280        // the TNLP (the adapter discarded them after classification) and
281        // pick out the entries pointed at by the `*_map` tables. -----
282        let n_full_x = classification.n_full_x as usize;
283        let n_full_g = classification.n_full_g as usize;
284        let mut full_x_l = vec![0.0; n_full_x];
285        let mut full_x_u = vec![0.0; n_full_x];
286        let mut full_g_l = vec![0.0; n_full_g];
287        let mut full_g_u = vec![0.0; n_full_g];
288        {
289            let a = adapter.borrow();
290            let mut t = a.tnlp().borrow_mut();
291            let ok = t.get_bounds_info(crate::tnlp::BoundsInfo {
292                x_l: &mut full_x_l,
293                x_u: &mut full_x_u,
294                g_l: &mut full_g_l,
295                g_u: &mut full_g_u,
296            });
297            if !ok {
298                return Err("TNLP::get_bounds_info returned false on second call".into());
299            }
300        }
301
302        let x_l = make_dense_from(&x_l_space, |i| {
303            // x_l_map[i] is an index into x_var (== index into x_not_fixed_map).
304            let var_idx = classification.x_l_map[i] as usize;
305            let full_idx = classification.x_not_fixed_map[var_idx] as usize;
306            full_x_l[full_idx]
307        });
308        let x_u = make_dense_from(&x_u_space, |i| {
309            let var_idx = classification.x_u_map[i] as usize;
310            let full_idx = classification.x_not_fixed_map[var_idx] as usize;
311            full_x_u[full_idx]
312        });
313        let d_l = make_dense_from(&d_l_space, |i| {
314            // d_l_map[i] is an index into d (== position in d_map).
315            let d_idx = classification.d_l_map[i] as usize;
316            let full_g_idx = classification.d_map[d_idx] as usize;
317            full_g_l[full_g_idx]
318        });
319        let d_u = make_dense_from(&d_u_space, |i| {
320            let d_idx = classification.d_u_map[i] as usize;
321            let full_g_idx = classification.d_map[d_idx] as usize;
322            full_g_u[full_g_idx]
323        });
324
325        // ---- Jacobian sparsity. Ask the TNLP for the full jacobian
326        // structure (g rows × full-x cols), then split entries into
327        // c-rows (equality) and d-rows (inequality). Within each split
328        // the row index is remapped to the new dense indexing in
329        // [0, n_c) / [0, n_d).
330        //
331        // We currently keep all original full-x columns (no fixed-var
332        // removal); when fixed-var treatment lands, this is where the
333        // column remap goes. -----
334        let mut full_irow = vec![0 as Index; info.nnz_jac_g as usize];
335        let mut full_jcol = vec![0 as Index; info.nnz_jac_g as usize];
336        {
337            let a = adapter.borrow();
338            let mut t = a.tnlp().borrow_mut();
339            let ok = t.eval_jac_g(
340                None,
341                false,
342                SparsityRequest::Structure {
343                    irow: &mut full_irow,
344                    jcol: &mut full_jcol,
345                },
346            );
347            if !ok {
348                return Err("TNLP::eval_jac_g(Structure) returned false".into());
349            }
350        }
351
352        // Build the inverse maps: g-row → c-row (or d-row).
353        let mut g_to_c = vec![-1 as Index; n_full_g];
354        for (c_idx, &g_idx) in classification.c_map.iter().enumerate() {
355            g_to_c[g_idx as usize] = c_idx as Index;
356        }
357        let mut g_to_d = vec![-1 as Index; n_full_g];
358        for (d_idx, &g_idx) in classification.d_map.iter().enumerate() {
359            g_to_d[g_idx as usize] = d_idx as Index;
360        }
361
362        let style_offset = match info.index_style {
363            crate::tnlp::IndexStyle::C => 0 as Index,
364            crate::tnlp::IndexStyle::Fortran => 1 as Index,
365        };
366
367        let mut jac_c_irow_1based = Vec::new();
368        let mut jac_c_jcol_1based = Vec::new();
369        let mut jac_c_entry_in_g = Vec::new();
370        let mut jac_d_irow_1based = Vec::new();
371        let mut jac_d_jcol_1based = Vec::new();
372        let mut jac_d_entry_in_g = Vec::new();
373
374        // `make_parameter`: drop Jacobian entries in fixed-variable
375        // columns. Their contribution to f and g is constant under the
376        // active-x search so they don't appear in the KKT.
377        let full_to_var = &classification.full_to_var;
378        for k in 0..info.nnz_jac_g as usize {
379            let g_row_0 = (full_irow[k] - style_offset) as usize;
380            let x_col_0 = (full_jcol[k] - style_offset) as usize;
381            let var_col = full_to_var[x_col_0];
382            if var_col < 0 {
383                continue;
384            }
385            // Triplet output is 1-based (matches `GenTMatrix` convention).
386            let col_1based = var_col + 1;
387            let c_row = g_to_c[g_row_0];
388            if c_row >= 0 {
389                jac_c_irow_1based.push(c_row + 1);
390                jac_c_jcol_1based.push(col_1based);
391                jac_c_entry_in_g.push(k as Index);
392            } else {
393                let d_row = g_to_d[g_row_0];
394                debug_assert!(d_row >= 0, "g row {g_row_0} is neither in c_map nor d_map");
395                jac_d_irow_1based.push(d_row + 1);
396                jac_d_jcol_1based.push(col_1based);
397                jac_d_entry_in_g.push(k as Index);
398            }
399        }
400
401        let jac_c_space = GenTMatrixSpace::new(
402            classification.n_c,
403            n_x_var,
404            jac_c_irow_1based,
405            jac_c_jcol_1based,
406        );
407        let jac_d_space = GenTMatrixSpace::new(
408            classification.n_d,
409            n_x_var,
410            jac_d_irow_1based,
411            jac_d_jcol_1based,
412        );
413
414        // ---- Hessian sparsity (optional). If the TNLP doesn't
415        // implement `eval_h`, we leave `h_space = None`. The Phase-8
416        // limited-memory quasi-Newton path will populate it from
417        // `LowRankUpdateSymMatrixSpace` instead. -----
418        let nnz_h_lag_full = info.nnz_h_lag;
419        let mut h_entry_in_full: Vec<Index> = Vec::new();
420        let h_space = if info.nnz_h_lag > 0 {
421            let mut h_irow = vec![0 as Index; info.nnz_h_lag as usize];
422            let mut h_jcol = vec![0 as Index; info.nnz_h_lag as usize];
423            let supports_h = {
424                let a = adapter.borrow();
425                let mut t = a.tnlp().borrow_mut();
426                t.eval_h(
427                    None,
428                    false,
429                    1.0,
430                    None,
431                    false,
432                    SparsityRequest::Structure {
433                        irow: &mut h_irow,
434                        jcol: &mut h_jcol,
435                    },
436                )
437            };
438            if supports_h {
439                // `make_parameter`: drop Hessian entries where row OR
440                // column is fixed (the second derivatives w.r.t. a
441                // parameter are not needed in the active-x KKT). The
442                // surviving entries are remapped from full-x indices
443                // to var-x indices via `full_to_var`.
444                let mut h_irow_1: Vec<Index> = Vec::with_capacity(h_irow.len());
445                let mut h_jcol_1: Vec<Index> = Vec::with_capacity(h_jcol.len());
446                for k in 0..h_irow.len() {
447                    let i_full = (h_irow[k] - style_offset) as usize;
448                    let j_full = (h_jcol[k] - style_offset) as usize;
449                    let i_var = full_to_var[i_full];
450                    let j_var = full_to_var[j_full];
451                    if i_var < 0 || j_var < 0 {
452                        continue;
453                    }
454                    h_irow_1.push(i_var + 1);
455                    h_jcol_1.push(j_var + 1);
456                    h_entry_in_full.push(k as Index);
457                }
458                Some(SymTMatrixSpace::new(n_x_var, h_irow_1, h_jcol_1))
459            } else {
460                // TODO(Phase 8): wire the L-BFGS / SR1 path here.
461                None
462            }
463        } else {
464            // LPs and other problems with structurally zero Hessian: build an
465            // empty SymTMatrixSpace so eval_h_internal returns a zero matrix
466            // rather than panicking down the L-BFGS error path.
467            Some(SymTMatrixSpace::new(n_x_var, Vec::new(), Vec::new()))
468        };
469
470        Ok(Self {
471            adapter,
472            scaling,
473            obj_scale_factor: Cell::new(1.0),
474            c_scale: RefCell::new(None),
475            d_scale: RefCell::new(None),
476            x_space,
477            c_space,
478            d_space,
479            x_l_space,
480            x_u_space,
481            d_l_space,
482            d_u_space,
483            px_l_space,
484            px_u_space,
485            pd_l_space,
486            pd_u_space,
487            jac_c_space,
488            jac_d_space,
489            h_space,
490            x_l: Rc::new(x_l),
491            x_u: Rc::new(x_u),
492            d_l: Rc::new(d_l),
493            d_u: Rc::new(d_u),
494            px_l,
495            px_u,
496            pd_l,
497            pd_u,
498            jac_c_entry_in_g,
499            jac_d_entry_in_g,
500            nnz_jac_g_full: info.nnz_jac_g,
501            nnz_h_lag_full,
502            h_entry_in_full,
503            f_cache: RefCell::new(Cache::new(1)),
504            grad_f_cache: RefCell::new(Cache::new(1)),
505            c_cache: RefCell::new(Cache::new(1)),
506            d_cache: RefCell::new(Cache::new(1)),
507            jac_c_cache: RefCell::new(Cache::new(1)),
508            jac_d_cache: RefCell::new(Cache::new(1)),
509            h_cache: RefCell::new(Cache::new(1)),
510            f_evals: RefCell::new(0),
511            grad_f_evals: RefCell::new(0),
512            c_evals: RefCell::new(0),
513            d_evals: RefCell::new(0),
514            jac_c_evals: RefCell::new(0),
515            jac_d_evals: RefCell::new(0),
516            h_evals: RefCell::new(0),
517            info,
518            timing: RefCell::new(None),
519        })
520    }
521
522    /// Install the shared timing accumulator. `IpoptApplication` calls
523    /// this once per solve so each `eval_*` entrypoint records into the
524    /// same `TimingStatistics` instance the algorithm reports at the
525    /// end of the run. Calling with `None` (or never calling) leaves
526    /// timing disabled.
527    pub fn set_timing_stats(&self, t: Rc<TimingStatistics>) {
528        *self.timing.borrow_mut() = Some(t);
529    }
530
531    /// Run `f` with two timers active for the duration of the call:
532    /// `pick(&timing)` (e.g. `eval_obj`) and `total_function_evaluation_time`.
533    /// When no `TimingStatistics` is installed, the closure is invoked
534    /// directly with no overhead.
535    fn timed_eval<R, F>(&self, pick: fn(&TimingStatistics) -> &pounce_common::TimedTask, f: F) -> R
536    where
537        F: FnOnce() -> R,
538    {
539        let guard = self.timing.borrow();
540        match guard.as_deref() {
541            Some(t) => {
542                let task = pick(t);
543                task.start();
544                t.total_function_evaluation_time.start();
545                let r = f();
546                t.total_function_evaluation_time.end();
547                task.end();
548                r
549            }
550            None => {
551                drop(guard);
552                f()
553            }
554        }
555    }
556
557    // ---- accessors used by the algorithm wiring layer ----
558
559    pub fn nlp_info(&self) -> &NlpInfo {
560        &self.info
561    }
562    pub fn classification_n_x_var(&self) -> Index {
563        self.x_space.dim()
564    }
565    pub fn x_space(&self) -> &Rc<DenseVectorSpace> {
566        &self.x_space
567    }
568    pub fn c_space(&self) -> &Rc<DenseVectorSpace> {
569        &self.c_space
570    }
571    pub fn d_space(&self) -> &Rc<DenseVectorSpace> {
572        &self.d_space
573    }
574    pub fn x_l_space(&self) -> &Rc<DenseVectorSpace> {
575        &self.x_l_space
576    }
577    pub fn x_u_space(&self) -> &Rc<DenseVectorSpace> {
578        &self.x_u_space
579    }
580    pub fn d_l_space(&self) -> &Rc<DenseVectorSpace> {
581        &self.d_l_space
582    }
583    pub fn d_u_space(&self) -> &Rc<DenseVectorSpace> {
584        &self.d_u_space
585    }
586    pub fn px_l_space(&self) -> &Rc<ExpansionMatrixSpace> {
587        &self.px_l_space
588    }
589    pub fn px_u_space(&self) -> &Rc<ExpansionMatrixSpace> {
590        &self.px_u_space
591    }
592    pub fn pd_l_space(&self) -> &Rc<ExpansionMatrixSpace> {
593        &self.pd_l_space
594    }
595    pub fn pd_u_space(&self) -> &Rc<ExpansionMatrixSpace> {
596        &self.pd_u_space
597    }
598    pub fn jac_c_space(&self) -> &Rc<GenTMatrixSpace> {
599        &self.jac_c_space
600    }
601    pub fn jac_d_space(&self) -> &Rc<GenTMatrixSpace> {
602        &self.jac_d_space
603    }
604    pub fn h_space(&self) -> Option<&Rc<SymTMatrixSpace>> {
605        self.h_space.as_ref()
606    }
607
608    /// Effective objective scaling factor (`df_` upstream). 1.0 when
609    /// no scaling has been determined.
610    pub fn obj_scale_factor(&self) -> Number {
611        self.obj_scale_factor.get()
612    }
613
614    /// Apply `bound_relax_factor` to the unscaled `x_L / x_U / d_L / d_U`
615    /// in place. Mirrors `OrigIpoptNLP::relax_bounds`
616    /// (`IpOrigIpoptNLP.cpp:343-358, 459-481`):
617    ///
618    /// `delta_i = min(constr_viol_tol, |relax| * max(|bound_i|, 1))`,
619    /// then `x_L -= delta`, `x_U += delta`, `d_L -= delta`, `d_U += delta`.
620    ///
621    /// Must be called before `determine_scaling_from_starting_point`
622    /// (which only reads bounds via cached evals — so order doesn't
623    /// affect scaling — but the bounds themselves should be the
624    /// post-relax values when they enter the algorithm).
625    pub fn relax_bounds(&mut self, bound_relax_factor: Number, constr_viol_tol: Number) {
626        if bound_relax_factor <= 0.0 {
627            return;
628        }
629        let relax = bound_relax_factor.abs();
630        let cap = constr_viol_tol;
631        let apply = |v: &mut DenseVector, sign: Number| {
632            let xs = v.values_mut();
633            for x in xs.iter_mut() {
634                let delta = (relax * x.abs().max(1.0)).min(cap);
635                *x += sign * delta;
636            }
637        };
638        if let Some(x_l) = Rc::get_mut(&mut self.x_l) {
639            apply(x_l, -1.0);
640        }
641        if let Some(x_u) = Rc::get_mut(&mut self.x_u) {
642            apply(x_u, 1.0);
643        }
644        if let Some(d_l) = Rc::get_mut(&mut self.d_l) {
645            apply(d_l, -1.0);
646        }
647        if let Some(d_u) = Rc::get_mut(&mut self.d_u) {
648            apply(d_u, 1.0);
649        }
650    }
651
652    /// Determine objective + per-constraint scaling from the starting
653    /// point, per `Algorithm/IpGradientScaling.cpp::DetermineScalingParametersImpl`
654    /// (and now also `nlp_scaling_method=user-scaling`). Should be called
655    /// once, after construction and before the algorithm enters its main
656    /// loop.
657    ///
658    /// Arguments:
659    /// * `method` — `None` / `GradientBased` / `UserScaling`.
660    /// * `max_gradient` — `nlp_scaling_max_gradient` (cutoff above which
661    ///   gradient-based scaling fires; default 100).
662    /// * `min_value` — `nlp_scaling_min_value` (floor on computed scale
663    ///   factors; default 1e-8).
664    /// * `obj_target_gradient` — `nlp_scaling_obj_target_gradient`
665    ///   (default 0; when `> 0`, fixes `df = obj_target_gradient /
666    ///   max_grad_f` unconditionally, overriding the cutoff).
667    /// * `constr_target_gradient` — `nlp_scaling_constr_target_gradient`
668    ///   (default 0; when `> 0`, fixes per-row scale to
669    ///   `constr_target_gradient / row_max` unconditionally).
670    ///
671    /// Cache state is invalidated so subsequent eval calls produce
672    /// scaled values.
673    pub fn determine_scaling_from_starting_point(
674        &mut self,
675        method: ScalingMethod,
676        max_gradient: Number,
677        min_value: Number,
678        obj_target_gradient: Number,
679        constr_target_gradient: Number,
680    ) {
681        // Always pull the user's `obj_scaling_factor` constant first;
682        // it multiplies whatever the automatic scheme computes.
683        let user_obj_factor = self.scaling.obj_scaling();
684        if matches!(method, ScalingMethod::None) {
685            self.obj_scale_factor.set(user_obj_factor);
686            *self.c_scale.borrow_mut() = None;
687            *self.d_scale.borrow_mut() = None;
688            self.invalidate_eval_caches();
689            return;
690        }
691
692        // ---- Get starting x_full (needed by both gradient + user paths) ----
693        let cls = self.adapter.borrow().classification().clone();
694        let n_full_x = cls.n_full_x as usize;
695        let n_full_g = cls.n_full_g as usize;
696        let mut full_x = vec![0.0; n_full_x];
697        let mut full_z_l = vec![0.0; n_full_x];
698        let mut full_z_u = vec![0.0; n_full_x];
699        let mut full_lambda = vec![0.0; n_full_g];
700        let starting_ok = {
701            let a = self.adapter.borrow();
702            let mut t = a.tnlp().borrow_mut();
703            t.get_starting_point(StartingPoint {
704                init_x: true,
705                x: &mut full_x,
706                init_z: false,
707                z_l: &mut full_z_l,
708                z_u: &mut full_z_u,
709                init_lambda: false,
710                lambda: &mut full_lambda,
711            })
712        };
713        if !starting_ok {
714            // Fall back to no automatic scaling.
715            self.obj_scale_factor.set(user_obj_factor);
716            *self.c_scale.borrow_mut() = None;
717            *self.d_scale.borrow_mut() = None;
718            self.invalidate_eval_caches();
719            return;
720        }
721
722        // Lift fixed variables (x_l == x_u) to their fixed value before
723        // sampling the gradient / Jacobian. Fixed vars never enter the
724        // algorithm's compressed x; every algorithm-side eval re-inserts
725        // their fixed value via `lift_x_to_full`, so scaling must be
726        // computed at that same point. Upstream achieves this implicitly:
727        // `TNLPAdapter::GetStartingPoint` projects the start onto the
728        // (relaxed) bounds, pinning fixed vars to their value. A raw `x0`
729        // that leaves them elsewhere can shift the objective gradient by
730        // orders of magnitude (pounce: flosp2hm — 41 fixed vars sitting at
731        // x0=0 instead of their fixed value 1 made ‖∇f‖∞ read 40 instead of
732        // 2.4e5, so obj_scale_factor stayed 1.0 and the solve stalled at
733        // max-iter while IPOPT, scaling correctly, converged in 5 iters).
734        for (i, &full_idx) in cls.x_fixed_map.iter().enumerate() {
735            full_x[full_idx as usize] = cls.x_fixed_vals[i];
736        }
737
738        match method {
739            ScalingMethod::None => unreachable!("handled above"),
740            ScalingMethod::GradientBased => {
741                self.scale_gradient_based(
742                    &cls,
743                    &full_x,
744                    user_obj_factor,
745                    max_gradient,
746                    min_value,
747                    obj_target_gradient,
748                    constr_target_gradient,
749                );
750            }
751            ScalingMethod::UserScaling => {
752                let applied = self.scale_user_supplied(&cls, user_obj_factor, min_value);
753                if !applied {
754                    // TNLP declined to supply scaling — fall through to
755                    // no automatic scaling (matches upstream's behavior
756                    // when `get_scaling_parameters` returns false).
757                    self.obj_scale_factor.set(user_obj_factor);
758                    *self.c_scale.borrow_mut() = None;
759                    *self.d_scale.borrow_mut() = None;
760                }
761            }
762        }
763
764        // Apply the d-row scaling to the d_l/d_u bound vectors so
765        // feasibility checks compare like with like (gh#54).
766        self.apply_d_scale_to_bounds();
767
768        // Drop any cached eval results computed before the scales were
769        // set (their values would be wrong now).
770        self.invalidate_eval_caches();
771    }
772
773    /// Gradient-based pathway: compute `df_`, `dc_`, `dd_` from the
774    /// objective gradient and constraint Jacobian at the starting point.
775    fn scale_gradient_based(
776        &self,
777        cls: &BoundClassification,
778        full_x: &[Number],
779        user_obj_factor: Number,
780        max_gradient: Number,
781        min_value: Number,
782        obj_target_gradient: Number,
783        constr_target_gradient: Number,
784    ) {
785        let n_full_x = cls.n_full_x as usize;
786        let n_full_g = cls.n_full_g as usize;
787
788        // ---- Objective gradient scale ----
789        let mut full_grad_f = vec![0.0; n_full_x];
790        let grad_ok = {
791            let a = self.adapter.borrow();
792            let mut t = a.tnlp().borrow_mut();
793            t.eval_grad_f(full_x, true, &mut full_grad_f)
794        };
795        let mut df = 1.0;
796        if grad_ok {
797            // Amax over the *compressed* x_var space (matches upstream
798            // which scales the algorithm-side gradient).
799            let mut max_grad_f: Number = 0.0;
800            for &full_idx in cls.x_not_fixed_map.iter() {
801                let v = full_grad_f[full_idx as usize].abs();
802                if v > max_grad_f {
803                    max_grad_f = v;
804                }
805            }
806            if obj_target_gradient > 0.0 && max_grad_f > 0.0 {
807                // Target overrides the cutoff (and the 1.0 clamp):
808                // pin gradient ∞-norm to the requested value.
809                df = obj_target_gradient / max_grad_f;
810            } else if max_grad_f > max_gradient {
811                df = max_gradient / max_grad_f;
812            }
813            if df < min_value {
814                df = min_value;
815            }
816        }
817        self.obj_scale_factor.set(df * user_obj_factor);
818
819        // ---- Constraint Jacobian row-max scaling ----
820        if cls.n_full_g == 0 {
821            *self.c_scale.borrow_mut() = None;
822            *self.d_scale.borrow_mut() = None;
823            return;
824        }
825        // Evaluate full Jacobian once at x.
826        let mut full_jac_vals = vec![0.0; self.nnz_jac_g_full as usize];
827        let jac_ok = {
828            let a = self.adapter.borrow();
829            let mut t = a.tnlp().borrow_mut();
830            t.eval_jac_g(
831                Some(full_x),
832                true,
833                SparsityRequest::Values {
834                    values: &mut full_jac_vals,
835                },
836            )
837        };
838        if !jac_ok {
839            *self.c_scale.borrow_mut() = None;
840            *self.d_scale.borrow_mut() = None;
841            return;
842        }
843        // Recover row indices from the sparsity structure.
844        let mut full_irow = vec![0 as Index; self.nnz_jac_g_full as usize];
845        let mut full_jcol = vec![0 as Index; self.nnz_jac_g_full as usize];
846        let _ = {
847            let a = self.adapter.borrow();
848            let mut t = a.tnlp().borrow_mut();
849            t.eval_jac_g(
850                None,
851                false,
852                SparsityRequest::Structure {
853                    irow: &mut full_irow,
854                    jcol: &mut full_jcol,
855                },
856            )
857        };
858        let style_offset: Index = match self.info.index_style {
859            crate::tnlp::IndexStyle::C => 0,
860            crate::tnlp::IndexStyle::Fortran => 1,
861        };
862        // Build inverse row maps to assign each entry to c or d.
863        let mut g_to_c = vec![-1 as Index; n_full_g];
864        for (c_idx, &g_idx) in cls.c_map.iter().enumerate() {
865            g_to_c[g_idx as usize] = c_idx as Index;
866        }
867        let mut g_to_d = vec![-1 as Index; n_full_g];
868        for (d_idx, &g_idx) in cls.d_map.iter().enumerate() {
869            g_to_d[g_idx as usize] = d_idx as Index;
870        }
871        let n_c = cls.n_c as usize;
872        let n_d = cls.n_d as usize;
873        // Initialize row-max arrays to dbl_min as upstream does.
874        let dbl_min = Number::MIN_POSITIVE;
875        let mut c_row_max: Vec<Number> = vec![dbl_min; n_c];
876        let mut d_row_max: Vec<Number> = vec![dbl_min; n_d];
877        for k in 0..self.nnz_jac_g_full as usize {
878            let g_row_0 = (full_irow[k] - style_offset) as usize;
879            let v = full_jac_vals[k].abs();
880            let cr = g_to_c[g_row_0];
881            if cr >= 0 {
882                let row = cr as usize;
883                if v > c_row_max[row] {
884                    c_row_max[row] = v;
885                }
886            } else {
887                let dr = g_to_d[g_row_0];
888                if dr >= 0 {
889                    let row = dr as usize;
890                    if v > d_row_max[row] {
891                        d_row_max[row] = v;
892                    }
893                }
894            }
895        }
896
897        let row_max_to_scale = |row_max: Number| -> Number {
898            // With `constr_target_gradient` > 0 the user is asking for
899            // a *fixed* gradient ∞-norm per row (overrides the cutoff
900            // and the 1.0 clamp). Otherwise: scale only rows that
901            // exceed the cutoff, never amplify (clamp at 1).
902            let mut s = if constr_target_gradient > 0.0 {
903                constr_target_gradient / row_max
904            } else {
905                let raw = max_gradient / row_max;
906                if raw > 1.0 {
907                    1.0
908                } else {
909                    raw
910                }
911            };
912            if s < min_value {
913                s = min_value;
914            }
915            s
916        };
917        let any_row_above = |rows: &[Number]| -> bool {
918            constr_target_gradient > 0.0 || rows.iter().any(|&v| v > max_gradient)
919        };
920
921        if n_c > 0 && any_row_above(&c_row_max) {
922            let dc: Vec<Number> = c_row_max.iter().map(|&v| row_max_to_scale(v)).collect();
923            *self.c_scale.borrow_mut() = Some(dc);
924        } else {
925            *self.c_scale.borrow_mut() = None;
926        }
927
928        if n_d > 0 && any_row_above(&d_row_max) {
929            let dd: Vec<Number> = d_row_max.iter().map(|&v| row_max_to_scale(v)).collect();
930            *self.d_scale.borrow_mut() = Some(dd);
931        } else {
932            *self.d_scale.borrow_mut() = None;
933        }
934    }
935
936    /// User-supplied scaling pathway: call `TNLP::get_scaling_parameters`
937    /// and translate the user's `obj_scaling` and `g_scaling` arrays
938    /// into the algorithm-side `obj_scale_factor`, `c_scale`, `d_scale`.
939    /// Returns `true` if the TNLP supplied scaling (matches upstream's
940    /// `GetScalingParameters` return-value contract).
941    ///
942    /// The `x_scaling` request channel is ignored: `OrigIpoptNlp` does
943    /// not currently model per-variable rescaling (would require
944    /// transforming `eval_grad_f`, `eval_jac_*`, and `eval_h` in
945    /// concert), and issue #61's `nlp_scaling=user` design explicitly
946    /// covers only `obj_scale` and `con_scale`.
947    fn scale_user_supplied(
948        &self,
949        cls: &BoundClassification,
950        user_obj_factor: Number,
951        min_value: Number,
952    ) -> bool {
953        let n_full_x = cls.n_full_x as usize;
954        let n_full_g = cls.n_full_g as usize;
955        let mut obj_scaling: Number = 1.0;
956        let mut use_x_scaling = false;
957        let mut x_scaling = vec![1.0; n_full_x];
958        let mut use_g_scaling = false;
959        let mut g_scaling = vec![1.0; n_full_g];
960        let ok = {
961            let a = self.adapter.borrow();
962            let mut t = a.tnlp().borrow_mut();
963            t.get_scaling_parameters(ScalingRequest {
964                obj_scaling: &mut obj_scaling,
965                use_x_scaling: &mut use_x_scaling,
966                x_scaling: &mut x_scaling,
967                use_g_scaling: &mut use_g_scaling,
968                g_scaling: &mut g_scaling,
969            })
970        };
971        if !ok {
972            return false;
973        }
974
975        // Objective: user's obj_scaling combined with the constant
976        // `obj_scaling_factor` (matches upstream's
977        // `StandardScalingBase::DetermineScaling`).
978        let mut df = obj_scaling;
979        if df.abs() < min_value {
980            // Defensively floor — a zero/near-zero obj scale would
981            // make all duals divide-by-zero on the way out.
982            df = df.signum().max(0.0).max(1.0) * min_value;
983        }
984        self.obj_scale_factor.set(df * user_obj_factor);
985
986        // Constraint vector: split user g_scaling into c_scale / d_scale.
987        if use_g_scaling && g_scaling.len() == n_full_g {
988            let n_c = cls.n_c as usize;
989            let n_d = cls.n_d as usize;
990            let mut dc = vec![1.0; n_c];
991            for (c_idx, &g_idx) in cls.c_map.iter().enumerate() {
992                let s = g_scaling[g_idx as usize];
993                dc[c_idx] = if s < min_value { min_value } else { s };
994            }
995            let mut dd = vec![1.0; n_d];
996            for (d_idx, &g_idx) in cls.d_map.iter().enumerate() {
997                let s = g_scaling[g_idx as usize];
998                dd[d_idx] = if s < min_value { min_value } else { s };
999            }
1000            // Only install the vectors when not all-ones (matches the
1001            // `Option::None ↔ identity` convention used elsewhere).
1002            let nontrivial_c = dc.iter().any(|&s| s != 1.0);
1003            *self.c_scale.borrow_mut() = if nontrivial_c && n_c > 0 {
1004                Some(dc)
1005            } else {
1006                None
1007            };
1008            let nontrivial_d = dd.iter().any(|&s| s != 1.0);
1009            *self.d_scale.borrow_mut() = if nontrivial_d && n_d > 0 {
1010                Some(dd)
1011            } else {
1012                None
1013            };
1014        } else {
1015            *self.c_scale.borrow_mut() = None;
1016            *self.d_scale.borrow_mut() = None;
1017        }
1018        // `use_x_scaling`: silently ignored (not modeled — see doc).
1019        let _ = use_x_scaling;
1020        true
1021    }
1022
1023    /// Bring `d_l` / `d_u` into the scaled space so feasibility checks
1024    /// compare like with like (gh#54). Upstream's
1025    /// `OrigIpoptNLP::Initialize` does this via
1026    /// `Pd_L_->TransMultVector(scaling.apply_vec_d(...))`.
1027    fn apply_d_scale_to_bounds(&mut self) {
1028        let cls = self.adapter.borrow().classification().clone();
1029        if let Some(dd) = self.d_scale.borrow().as_ref() {
1030            if let Some(d_l) = Rc::get_mut(&mut self.d_l) {
1031                let xs = d_l.values_mut();
1032                for (i, slot) in xs.iter_mut().enumerate() {
1033                    let d_idx = cls.d_l_map[i] as usize;
1034                    *slot *= dd[d_idx];
1035                }
1036            }
1037            if let Some(d_u) = Rc::get_mut(&mut self.d_u) {
1038                let xs = d_u.values_mut();
1039                for (i, slot) in xs.iter_mut().enumerate() {
1040                    let d_idx = cls.d_u_map[i] as usize;
1041                    *slot *= dd[d_idx];
1042                }
1043            }
1044        }
1045    }
1046
1047    fn invalidate_eval_caches(&self) {
1048        self.f_cache.borrow_mut().clear();
1049        self.grad_f_cache.borrow_mut().clear();
1050        self.c_cache.borrow_mut().clear();
1051        self.d_cache.borrow_mut().clear();
1052        self.jac_c_cache.borrow_mut().clear();
1053        self.jac_d_cache.borrow_mut().clear();
1054        self.h_cache.borrow_mut().clear();
1055    }
1056
1057    pub fn f_evals(&self) -> Index {
1058        *self.f_evals.borrow()
1059    }
1060    pub fn grad_f_evals(&self) -> Index {
1061        *self.grad_f_evals.borrow()
1062    }
1063    pub fn c_evals(&self) -> Index {
1064        *self.c_evals.borrow()
1065    }
1066    pub fn d_evals(&self) -> Index {
1067        *self.d_evals.borrow()
1068    }
1069    pub fn jac_c_evals(&self) -> Index {
1070        *self.jac_c_evals.borrow()
1071    }
1072    pub fn jac_d_evals(&self) -> Index {
1073        *self.jac_d_evals.borrow()
1074    }
1075    pub fn h_evals(&self) -> Index {
1076        *self.h_evals.borrow()
1077    }
1078
1079    /// Lift a compressed `x_var` (length `n_x_var`) up to the full TNLP
1080    /// `x` (length `n_full_x`), inserting `x_fixed_vals` at the
1081    /// `x_fixed_map` positions. Mirrors upstream
1082    /// `IpTNLPAdapter::ResortX` under `fixed_variable_treatment =
1083    /// make_parameter`.
1084    pub fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
1085        let Some(dx) = x.as_any().downcast_ref::<DenseVector>() else {
1086            panic!("OrigIpoptNlp expects DenseVector for x");
1087        };
1088        let a = self.adapter.borrow();
1089        let cls = a.classification();
1090        let mut full = vec![0.0; cls.n_full_x as usize];
1091        let vals = dx.expanded_values();
1092        for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1093            full[full_idx as usize] = vals[var_idx];
1094        }
1095        for (i, &full_idx) in cls.x_fixed_map.iter().enumerate() {
1096            full[full_idx as usize] = cls.x_fixed_vals[i];
1097        }
1098        full
1099    }
1100
1101    /// Lift the algorithm-side `(y_c, y_d)` multipliers to the user
1102    /// TNLP's `lambda` array (length `m_full = n_c + n_d`, indexed
1103    /// by original constraint-row order). Result matches the user's
1104    /// **unscaled-Lagrangian** convention `min f + λ·g(x)` —
1105    /// i.e. without the obj_factor that the algorithm threads through
1106    /// `eval_h`. Mirror of upstream
1107    /// `IpOrigIpoptNLP::FinalizeSolution`'s `mult_g` packing
1108    /// (`lambda_user = c_scale * y_c / obj_scale_factor`,
1109    /// `mu_user = d_scale * y_d / obj_scale_factor`). Used by
1110    /// `application.rs::finalize_via_orig_nlp` to populate the
1111    /// `Solution.lambda` slot — pounce#11.
1112    pub fn finalize_solution_lambda(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
1113        let cls = self.adapter.borrow().classification().clone();
1114        let mut lambda = self.pack_lambda_for_user(y_c, y_d, &cls);
1115        let obj_scal = self.obj_scale_factor.get();
1116        if obj_scal != 0.0 && obj_scal != 1.0 {
1117            let inv = 1.0 / obj_scal;
1118            for v in lambda.iter_mut() {
1119                *v *= inv;
1120            }
1121        }
1122        lambda
1123    }
1124
1125    /// Lift the algorithm-side compressed `z_l` (length `n_x_l`,
1126    /// indexed via `x_l_map`) to the user's full-x bound multiplier
1127    /// array (length `n_full_x`). Slots without a finite lower bound
1128    /// — including fixed variables — are reported as `0.0`. Sign and
1129    /// scale match upstream Ipopt: `z_l ≥ 0` for active lower
1130    /// bounds, divided by `obj_scale_factor` so the user sees the
1131    /// unscaled-Lagrangian dual.
1132    pub fn finalize_solution_z_l(&self, z_l: &dyn Vector) -> Vec<Number> {
1133        let cls = self.adapter.borrow().classification().clone();
1134        let n_full_x = cls.n_full_x as usize;
1135        let mut full_z_l = vec![0.0; n_full_x];
1136        let n_x_l = self.x_l.dim() as usize;
1137        if n_x_l == 0 {
1138            return full_z_l;
1139        }
1140        let Some(dz) = z_l.as_any().downcast_ref::<DenseVector>() else {
1141            panic!("OrigIpoptNlp::finalize_solution_z_l expects DenseVector");
1142        };
1143        let vals = dz.expanded_values();
1144        let obj_scal = self.obj_scale_factor.get();
1145        let inv = if obj_scal == 0.0 { 1.0 } else { 1.0 / obj_scal };
1146        for i in 0..n_x_l {
1147            let var_idx = cls.x_l_map[i] as usize;
1148            let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1149            full_z_l[full_idx] = vals[i] * inv;
1150        }
1151        full_z_l
1152    }
1153
1154    /// Mirror of [`Self::finalize_solution_z_l`] for the upper-bound
1155    /// duals. Indexed via `x_u_map`.
1156    pub fn finalize_solution_z_u(&self, z_u: &dyn Vector) -> Vec<Number> {
1157        let cls = self.adapter.borrow().classification().clone();
1158        let n_full_x = cls.n_full_x as usize;
1159        let mut full_z_u = vec![0.0; n_full_x];
1160        let n_x_u = self.x_u.dim() as usize;
1161        if n_x_u == 0 {
1162            return full_z_u;
1163        }
1164        let Some(dz) = z_u.as_any().downcast_ref::<DenseVector>() else {
1165            panic!("OrigIpoptNlp::finalize_solution_z_u expects DenseVector");
1166        };
1167        let vals = dz.expanded_values();
1168        let obj_scal = self.obj_scale_factor.get();
1169        let inv = if obj_scal == 0.0 { 1.0 } else { 1.0 / obj_scal };
1170        for i in 0..n_x_u {
1171            let var_idx = cls.x_u_map[i] as usize;
1172            let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1173            full_z_u[full_idx] = vals[i] * inv;
1174        }
1175        full_z_u
1176    }
1177
1178    /// Clone the user-provided multipliers (already in the
1179    /// algorithm's eq/ineq-split form) into a single `lambda` array of
1180    /// length `m_full = n_c + n_d` ordered by original g-index. Used
1181    /// by `eval_h` and `finalize_solution`.
1182    /// Pack the algorithm-side `(y_c, y_d)` multipliers into the user
1183    /// TNLP's `lambda` array (full-g indexed), applying c/d scale
1184    /// factors so the result is in the user's unscaled-constraint
1185    /// multiplier space (`lambda_user_i = c_scale_i * y_c_i`). Used
1186    /// when invoking the user's `eval_h`.
1187    pub fn pack_lambda_for_user(
1188        &self,
1189        y_c: &dyn Vector,
1190        y_d: &dyn Vector,
1191        cls: &BoundClassification,
1192    ) -> Vec<Number> {
1193        let mut lambda = vec![0.0; cls.n_full_g as usize];
1194        if cls.n_c > 0 {
1195            let Some(dy) = y_c.as_any().downcast_ref::<DenseVector>() else {
1196                panic!("OrigIpoptNlp expects DenseVector for y_c");
1197            };
1198            let vals = dy.expanded_values();
1199            let cs = self.c_scale.borrow();
1200            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1201                lambda[g_idx as usize] = match cs.as_ref() {
1202                    Some(v) => vals[i] * v[i],
1203                    None => vals[i],
1204                };
1205            }
1206        }
1207        if cls.n_d > 0 {
1208            let Some(dy) = y_d.as_any().downcast_ref::<DenseVector>() else {
1209                panic!("OrigIpoptNlp expects DenseVector for y_d");
1210            };
1211            let vals = dy.expanded_values();
1212            let ds = self.d_scale.borrow();
1213            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1214                lambda[g_idx as usize] = match ds.as_ref() {
1215                    Some(v) => vals[i] * v[i],
1216                    None => vals[i],
1217                };
1218            }
1219        }
1220        lambda
1221    }
1222
1223    // -------------------- Initialization --------------------
1224
1225    /// Fill the algorithm's iterate slots with the TNLP's starting
1226    /// point. Mirrors the second half of upstream
1227    /// `InitializeStructures`. The caller passes already-allocated
1228    /// `DenseVector`s in the right spaces; we set them in place.
1229    ///
1230    /// Returns the four `init_*` flags so the caller can decide
1231    /// whether to overwrite zeros with the user's guess.
1232    #[allow(clippy::too_many_arguments)]
1233    pub fn initialize_starting_point(
1234        &mut self,
1235        x: &mut DenseVector,
1236        init_x: bool,
1237        y_c: &mut DenseVector,
1238        init_y_c: bool,
1239        y_d: &mut DenseVector,
1240        init_y_d: bool,
1241        z_l: &mut DenseVector,
1242        init_z_l: bool,
1243        z_u: &mut DenseVector,
1244        init_z_u: bool,
1245    ) -> bool {
1246        let n_full_x = self.adapter.borrow().classification().n_full_x as usize;
1247        let n_full_g = self.adapter.borrow().classification().n_full_g as usize;
1248        let n_x_l = self.x_l.dim() as usize;
1249        let n_x_u = self.x_u.dim() as usize;
1250
1251        let mut full_x = vec![0.0; n_full_x];
1252        let mut full_z_l = vec![0.0; n_full_x];
1253        let mut full_z_u = vec![0.0; n_full_x];
1254        let mut full_lambda = vec![0.0; n_full_g];
1255
1256        let ok = {
1257            let a = self.adapter.borrow();
1258            let mut t = a.tnlp().borrow_mut();
1259            t.get_starting_point(StartingPoint {
1260                init_x,
1261                x: &mut full_x,
1262                init_z: init_z_l || init_z_u,
1263                z_l: &mut full_z_l,
1264                z_u: &mut full_z_u,
1265                init_lambda: init_y_c || init_y_d,
1266                lambda: &mut full_lambda,
1267            })
1268        };
1269        if !ok {
1270            return false;
1271        }
1272
1273        let cls = self.adapter.borrow().classification().clone();
1274        let obj_scal = self.obj_scale_factor.get();
1275        let c_scale = self.c_scale.borrow();
1276        let d_scale = self.d_scale.borrow();
1277
1278        // Compress full_x → x.
1279        if init_x {
1280            let xs = x.values_mut();
1281            for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1282                xs[var_idx] = full_x[full_idx as usize];
1283            }
1284        }
1285        // Compress full_lambda → y_c, y_d. Upstream
1286        // (`IpOrigIpoptNLP.cpp:407-429`) divides the user multiplier
1287        // by the constraint scale (`unapply_vector_scaling_*`) and
1288        // multiplies by obj_scal so that the algorithm-side y_c sees
1289        // `(obj_scal / c_scale) * lambda_user`.
1290        if init_y_c && cls.n_c > 0 {
1291            let yc = y_c.values_mut();
1292            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1293                let cs = c_scale.as_ref().map(|v| v[i]).unwrap_or(1.0);
1294                yc[i] = full_lambda[g_idx as usize] / cs * obj_scal;
1295            }
1296        }
1297        if init_y_d && cls.n_d > 0 {
1298            let yd = y_d.values_mut();
1299            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1300                let ds = d_scale.as_ref().map(|v| v[i]).unwrap_or(1.0);
1301                yd[i] = full_lambda[g_idx as usize] / ds * obj_scal;
1302            }
1303        }
1304        // Compress full_z_l, full_z_u → z_l, z_u, indexed via x_l_map / x_u_map.
1305        if init_z_l && n_x_l > 0 {
1306            let zl = z_l.values_mut();
1307            for (i, slot) in zl.iter_mut().enumerate().take(n_x_l) {
1308                let var_idx = cls.x_l_map[i] as usize;
1309                let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1310                *slot = full_z_l[full_idx] * obj_scal;
1311            }
1312        }
1313        if init_z_u && n_x_u > 0 {
1314            let zu = z_u.values_mut();
1315            for (i, slot) in zu.iter_mut().enumerate().take(n_x_u) {
1316                let var_idx = cls.x_u_map[i] as usize;
1317                let full_idx = cls.x_not_fixed_map[var_idx] as usize;
1318                *slot = full_z_u[full_idx] * obj_scal;
1319            }
1320        }
1321        true
1322    }
1323
1324    // -------------------- Internal eval helpers --------------------
1325
1326    fn eval_f_internal(&self, x: &dyn Vector) -> Number {
1327        if let Some(v) = self.f_cache.borrow().get_1dep(x.as_tagged()) {
1328            return v;
1329        }
1330        *self.f_evals.borrow_mut() += 1;
1331        let full_x = self.lift_x_to_full(x);
1332        let unscaled = {
1333            let a = self.adapter.borrow();
1334            let mut t = a.tnlp().borrow_mut();
1335            // A failed user eval (domain error, e.g. log of a negative) is
1336            // upstream Ipopt's `Eval_Error`. Return NaN so the line search's
1337            // non-finite-trial path backtracks the step, rather than aborting
1338            // — and a panic cannot unwind across the C FFI boundary anyway.
1339            t.eval_f(&full_x, true).unwrap_or(f64::NAN)
1340        };
1341        let scaled = unscaled * self.obj_scale_factor.get();
1342        self.f_cache.borrow_mut().add_1dep(scaled, x.as_tagged());
1343        scaled
1344    }
1345
1346    fn eval_grad_f_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1347        if let Some(v) = self.grad_f_cache.borrow().get_1dep(x.as_tagged()) {
1348            return v;
1349        }
1350        *self.grad_f_evals.borrow_mut() += 1;
1351        let full_x = self.lift_x_to_full(x);
1352        let mut full_g = vec![0.0; full_x.len()];
1353        let ok = {
1354            let a = self.adapter.borrow();
1355            let mut t = a.tnlp().borrow_mut();
1356            t.eval_grad_f(&full_x, true, &mut full_g)
1357        };
1358        // Eval failure → NaN-filled gradient, which propagates a non-finite
1359        // step the line search rejects (see `eval_f_internal`).
1360        if !ok {
1361            full_g.fill(f64::NAN);
1362        }
1363        // Compress full_g → grad in x_var-space, scale by obj_scal.
1364        let cls = self.adapter.borrow().classification().clone();
1365        let mut g_compressed = self.x_space.make_new_dense();
1366        let obj_scal = self.obj_scale_factor.get();
1367        {
1368            let gv = g_compressed.values_mut();
1369            for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1370                gv[var_idx] = full_g[full_idx as usize] * obj_scal;
1371            }
1372        }
1373        let result: Rc<dyn Vector> = Rc::new(g_compressed);
1374        self.grad_f_cache
1375            .borrow_mut()
1376            .add_1dep(Rc::clone(&result), x.as_tagged());
1377        result
1378    }
1379
1380    fn eval_c_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1381        let cls = self.adapter.borrow().classification().clone();
1382        if cls.n_c == 0 {
1383            // Empty constraint vector — still cache so the tag is stable.
1384            if let Some(v) = self.c_cache.borrow().get_1dep(x.as_tagged()) {
1385                return v;
1386            }
1387            let v = self.c_space.make_new_dense();
1388            let result: Rc<dyn Vector> = Rc::new(v);
1389            self.c_cache
1390                .borrow_mut()
1391                .add_1dep(Rc::clone(&result), x.as_tagged());
1392            return result;
1393        }
1394        if let Some(v) = self.c_cache.borrow().get_1dep(x.as_tagged()) {
1395            return v;
1396        }
1397        *self.c_evals.borrow_mut() += 1;
1398        let full_x = self.lift_x_to_full(x);
1399        let mut full_g = vec![0.0; cls.n_full_g as usize];
1400        let ok = {
1401            let a = self.adapter.borrow();
1402            let mut t = a.tnlp().borrow_mut();
1403            t.eval_g(&full_x, true, &mut full_g)
1404        };
1405        // Eval failure → NaN constraint values, so `theta_trial` goes
1406        // non-finite and the line search backtracks (see `eval_f_internal`).
1407        if !ok {
1408            full_g.fill(f64::NAN);
1409        }
1410        let mut c = self.c_space.make_new_dense();
1411        // c_i = g(g_idx) - g_l(g_idx)  (since g_l == g_u for equalities,
1412        // upstream subtracts the bound to make it a residual). Matches
1413        // `OrigIpoptNLP::c` which calls `nlp_->Eval_c` after the adapter
1414        // subtracted the bound — TNLPAdapter doesn't subtract yet, so we
1415        // do it here.
1416        let n_full_g = cls.n_full_g as usize;
1417        let mut full_g_l = vec![0.0; n_full_g];
1418        let mut full_g_u = vec![0.0; n_full_g];
1419        {
1420            let mut tmp_x_l = vec![0.0; cls.n_full_x as usize];
1421            let mut tmp_x_u = vec![0.0; cls.n_full_x as usize];
1422            let a = self.adapter.borrow();
1423            let mut t = a.tnlp().borrow_mut();
1424            t.get_bounds_info(crate::tnlp::BoundsInfo {
1425                x_l: &mut tmp_x_l,
1426                x_u: &mut tmp_x_u,
1427                g_l: &mut full_g_l,
1428                g_u: &mut full_g_u,
1429            });
1430        }
1431        {
1432            let cv = c.values_mut();
1433            let cs = self.c_scale.borrow();
1434            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1435                let raw = full_g[g_idx as usize] - full_g_l[g_idx as usize];
1436                cv[i] = match cs.as_ref() {
1437                    Some(v) => raw * v[i],
1438                    None => raw,
1439                };
1440            }
1441        }
1442        let result: Rc<dyn Vector> = Rc::new(c);
1443        self.c_cache
1444            .borrow_mut()
1445            .add_1dep(Rc::clone(&result), x.as_tagged());
1446        result
1447    }
1448
1449    fn eval_d_internal(&self, x: &dyn Vector) -> Rc<dyn Vector> {
1450        let cls = self.adapter.borrow().classification().clone();
1451        if cls.n_d == 0 {
1452            if let Some(v) = self.d_cache.borrow().get_1dep(x.as_tagged()) {
1453                return v;
1454            }
1455            let v = self.d_space.make_new_dense();
1456            let result: Rc<dyn Vector> = Rc::new(v);
1457            self.d_cache
1458                .borrow_mut()
1459                .add_1dep(Rc::clone(&result), x.as_tagged());
1460            return result;
1461        }
1462        if let Some(v) = self.d_cache.borrow().get_1dep(x.as_tagged()) {
1463            return v;
1464        }
1465        *self.d_evals.borrow_mut() += 1;
1466        let full_x = self.lift_x_to_full(x);
1467        let mut full_g = vec![0.0; cls.n_full_g as usize];
1468        let ok = {
1469            let a = self.adapter.borrow();
1470            let mut t = a.tnlp().borrow_mut();
1471            t.eval_g(&full_x, true, &mut full_g)
1472        };
1473        if !ok {
1474            full_g.fill(f64::NAN);
1475        }
1476        let mut d = self.d_space.make_new_dense();
1477        {
1478            let dv = d.values_mut();
1479            let ds = self.d_scale.borrow();
1480            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1481                let raw = full_g[g_idx as usize];
1482                dv[i] = match ds.as_ref() {
1483                    Some(v) => raw * v[i],
1484                    None => raw,
1485                };
1486            }
1487        }
1488        let result: Rc<dyn Vector> = Rc::new(d);
1489        self.d_cache
1490            .borrow_mut()
1491            .add_1dep(Rc::clone(&result), x.as_tagged());
1492        result
1493    }
1494
1495    fn eval_jac_c_internal(&self, x: &dyn Vector) -> Rc<dyn Matrix> {
1496        if let Some(m) = self.jac_c_cache.borrow().get_1dep(x.as_tagged()) {
1497            return m;
1498        }
1499        *self.jac_c_evals.borrow_mut() += 1;
1500        let mut full_vals = vec![0.0; self.nnz_jac_g_full as usize];
1501        let full_x = self.lift_x_to_full(x);
1502        let ok = {
1503            let a = self.adapter.borrow();
1504            let mut t = a.tnlp().borrow_mut();
1505            t.eval_jac_g(
1506                Some(&full_x),
1507                true,
1508                SparsityRequest::Values {
1509                    values: &mut full_vals,
1510                },
1511            )
1512        };
1513        if !ok {
1514            full_vals.fill(f64::NAN);
1515        }
1516        let mut jac_c = GenTMatrix::new(Rc::clone(&self.jac_c_space));
1517        {
1518            let cs = self.c_scale.borrow();
1519            let irows = self.jac_c_space.irows().to_vec();
1520            let vs = jac_c.values_mut();
1521            for (k, &src) in self.jac_c_entry_in_g.iter().enumerate() {
1522                let raw = full_vals[src as usize];
1523                vs[k] = match cs.as_ref() {
1524                    // irows are 1-based.
1525                    Some(v) => raw * v[(irows[k] - 1) as usize],
1526                    None => raw,
1527                };
1528            }
1529        }
1530        let result: Rc<dyn Matrix> = Rc::new(jac_c);
1531        self.jac_c_cache
1532            .borrow_mut()
1533            .add_1dep(Rc::clone(&result), x.as_tagged());
1534        result
1535    }
1536
1537    fn eval_jac_d_internal(&self, x: &dyn Vector) -> Rc<dyn Matrix> {
1538        if let Some(m) = self.jac_d_cache.borrow().get_1dep(x.as_tagged()) {
1539            return m;
1540        }
1541        *self.jac_d_evals.borrow_mut() += 1;
1542        let mut full_vals = vec![0.0; self.nnz_jac_g_full as usize];
1543        let full_x = self.lift_x_to_full(x);
1544        let ok = {
1545            let a = self.adapter.borrow();
1546            let mut t = a.tnlp().borrow_mut();
1547            t.eval_jac_g(
1548                Some(&full_x),
1549                true,
1550                SparsityRequest::Values {
1551                    values: &mut full_vals,
1552                },
1553            )
1554        };
1555        if !ok {
1556            full_vals.fill(f64::NAN);
1557        }
1558        let mut jac_d = GenTMatrix::new(Rc::clone(&self.jac_d_space));
1559        {
1560            let ds = self.d_scale.borrow();
1561            let irows = self.jac_d_space.irows().to_vec();
1562            let vs = jac_d.values_mut();
1563            for (k, &src) in self.jac_d_entry_in_g.iter().enumerate() {
1564                let raw = full_vals[src as usize];
1565                vs[k] = match ds.as_ref() {
1566                    Some(v) => raw * v[(irows[k] - 1) as usize],
1567                    None => raw,
1568                };
1569            }
1570        }
1571        let result: Rc<dyn Matrix> = Rc::new(jac_d);
1572        self.jac_d_cache
1573            .borrow_mut()
1574            .add_1dep(Rc::clone(&result), x.as_tagged());
1575        result
1576    }
1577
1578    fn eval_h_internal(
1579        &self,
1580        x: &dyn Vector,
1581        obj_factor: Number,
1582        y_c: &dyn Vector,
1583        y_d: &dyn Vector,
1584    ) -> Rc<dyn SymMatrix> {
1585        // h_cache key: (x, y_c, y_d) tags + obj_factor scalar dep, as
1586        // upstream `IpOrigIpoptNLP.cpp:786`.
1587        if let Some(m) = self.h_cache.borrow().get(
1588            &[x.as_tagged(), y_c.as_tagged(), y_d.as_tagged()],
1589            &[obj_factor],
1590        ) {
1591            return m;
1592        }
1593        *self.h_evals.borrow_mut() += 1;
1594        let Some(h_space) = self.h_space.as_ref() else {
1595            panic!(
1596                "OrigIpoptNlp::eval_h called but the TNLP did not provide \
1597                 eval_h sparsity. The L-BFGS path lands in Phase 8."
1598            );
1599        };
1600        let cls = self.adapter.borrow().classification().clone();
1601        let full_x = self.lift_x_to_full(x);
1602        // Upstream `IpOrigIpoptNLP.cpp:792-794` passes the user TNLP's
1603        // `eval_h` the multipliers in the user's unscaled-constraint
1604        // space, i.e. `lambda_user = c_scale * y_c` (and same for d).
1605        // The obj_factor is also scaled (`scaled_obj_factor = obj_scale
1606        // * obj_factor`). Together this gives the user-space Hessian
1607        // contribution that's already in the algorithm's scaled space
1608        // (no extra Hessian-side scaling because we don't scale x).
1609        let full_lambda = self.pack_lambda_for_user(y_c, y_d, &cls);
1610        let scaled_obj_factor = obj_factor * self.obj_scale_factor.get();
1611
1612        // The user TNLP writes `nnz_h_lag_full` values; the kept
1613        // (var-x ⊗ var-x) subset has `h_space.nonzeros()` entries
1614        // selected via `h_entry_in_full`. They differ when fixed
1615        // variables drop entries.
1616        let mut full_vals = vec![0.0; self.nnz_h_lag_full as usize];
1617        let ok = {
1618            let a = self.adapter.borrow();
1619            let mut t = a.tnlp().borrow_mut();
1620            t.eval_h(
1621                Some(&full_x),
1622                true,
1623                scaled_obj_factor,
1624                Some(&full_lambda),
1625                true,
1626                SparsityRequest::Values {
1627                    values: &mut full_vals,
1628                },
1629            )
1630        };
1631        if !ok {
1632            full_vals.fill(f64::NAN);
1633        }
1634        let mut h = SymTMatrix::new(Rc::clone(h_space));
1635        let kept = h_space.nonzeros() as usize;
1636        let h_vals = h.values_mut();
1637        // `h_entry_in_full` always has length `kept` (identity when no
1638        // fixed-var filtering, sparse selection otherwise).
1639        debug_assert_eq!(kept, self.h_entry_in_full.len());
1640        for (k, &src) in self.h_entry_in_full.iter().enumerate() {
1641            h_vals[k] = full_vals[src as usize];
1642        }
1643        let result: Rc<dyn SymMatrix> = Rc::new(h);
1644        self.h_cache.borrow_mut().add(
1645            Rc::clone(&result),
1646            &[x.as_tagged(), y_c.as_tagged(), y_d.as_tagged()],
1647            &[obj_factor],
1648        );
1649        result
1650    }
1651}
1652
1653// ---- helpers ----
1654
1655fn make_dense_from(
1656    space: &Rc<DenseVectorSpace>,
1657    mut f: impl FnMut(usize) -> Number,
1658) -> DenseVector {
1659    let mut v = space.make_new_dense();
1660    let dim = space.dim() as usize;
1661    if dim > 0 {
1662        let vs = v.values_mut();
1663        for (i, slot) in vs.iter_mut().enumerate().take(dim) {
1664            *slot = f(i);
1665        }
1666    }
1667    v
1668}
1669
1670// -------------------- Trait impls --------------------
1671
1672impl Nlp for OrigIpoptNlp {
1673    fn n(&self) -> Index {
1674        self.x_space.dim()
1675    }
1676    fn m_eq(&self) -> Index {
1677        self.c_space.dim()
1678    }
1679    fn m_ineq(&self) -> Index {
1680        self.d_space.dim()
1681    }
1682
1683    fn eval_f(&mut self, x: &dyn Vector) -> Number {
1684        self.timed_eval(|t| &t.eval_obj, || self.eval_f_internal(x))
1685    }
1686    fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1687        let result = self.timed_eval(|t| &t.eval_grad_obj, || self.eval_grad_f_internal(x));
1688        g.copy(&*result);
1689    }
1690    fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector) {
1691        let result = self.timed_eval(|t| &t.eval_constr, || self.eval_c_internal(x));
1692        c.copy(&*result);
1693    }
1694    fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector) {
1695        let result = self.timed_eval(|t| &t.eval_constr, || self.eval_d_internal(x));
1696        d.copy(&*result);
1697    }
1698    fn eval_jac_c(&mut self, x: &dyn Vector) -> Rc<dyn Matrix> {
1699        self.timed_eval(|t| &t.eval_constr_jac, || self.eval_jac_c_internal(x))
1700    }
1701    fn eval_jac_d(&mut self, x: &dyn Vector) -> Rc<dyn Matrix> {
1702        self.timed_eval(|t| &t.eval_constr_jac, || self.eval_jac_d_internal(x))
1703    }
1704    fn eval_h(
1705        &mut self,
1706        x: &dyn Vector,
1707        obj_factor: Number,
1708        y_c: &dyn Vector,
1709        y_d: &dyn Vector,
1710    ) -> Rc<dyn SymMatrix> {
1711        self.timed_eval(
1712            |t| &t.eval_lag_hess,
1713            || self.eval_h_internal(x, obj_factor, y_c, y_d),
1714        )
1715    }
1716}
1717
1718impl IpoptNlp for OrigIpoptNlp {
1719    fn x_l(&self) -> &dyn Vector {
1720        &*self.x_l
1721    }
1722    fn x_u(&self) -> &dyn Vector {
1723        &*self.x_u
1724    }
1725    fn d_l(&self) -> &dyn Vector {
1726        &*self.d_l
1727    }
1728    fn d_u(&self) -> &dyn Vector {
1729        &*self.d_u
1730    }
1731    fn px_l(&self) -> Rc<dyn Matrix> {
1732        Rc::clone(&self.px_l)
1733    }
1734    fn px_u(&self) -> Rc<dyn Matrix> {
1735        Rc::clone(&self.px_u)
1736    }
1737    fn pd_l(&self) -> Rc<dyn Matrix> {
1738        Rc::clone(&self.pd_l)
1739    }
1740    fn pd_u(&self) -> Rc<dyn Matrix> {
1741        Rc::clone(&self.pd_u)
1742    }
1743
1744    /// Install moved bounds from the safe-slack mechanism. Mirrors
1745    /// `OrigIpoptNLP::AdjustVariableBounds` (`IpOrigIpoptNLP.cpp:990`):
1746    /// upstream simply swaps in the new bound vectors. We copy the values
1747    /// into the existing `Rc<DenseVector>` storage (falling back to a
1748    /// fresh allocation if the bound is somehow shared), which leaves the
1749    /// `Px_* / Pd_*` expansion matrices — keyed on the bound *spaces*,
1750    /// not values — untouched.
1751    fn adjust_variable_bounds(
1752        &mut self,
1753        new_x_l: &dyn Vector,
1754        new_x_u: &dyn Vector,
1755        new_d_l: &dyn Vector,
1756        new_d_u: &dyn Vector,
1757    ) {
1758        // The bound `Rc`s are uniquely owned (nothing clones them — same
1759        // invariant `relax_bounds` relies on), so `get_mut` always
1760        // succeeds and we copy the moved values into the existing storage.
1761        fn install(slot: &mut Rc<DenseVector>, new: &dyn Vector) {
1762            Rc::get_mut(slot)
1763                .expect("adjust_variable_bounds: bound vector is uniquely owned")
1764                .copy(new);
1765        }
1766        install(&mut self.x_l, new_x_l);
1767        install(&mut self.x_u, new_x_u);
1768        install(&mut self.d_l, new_d_l);
1769        install(&mut self.d_u, new_d_u);
1770    }
1771
1772    fn obj_scaling_factor(&self) -> Number {
1773        self.obj_scale_factor.get()
1774    }
1775
1776    /// Project the underlying TNLP's `idx_names` metadata into the
1777    /// algorithm's split space. Variable names are gathered through the
1778    /// fixed-variable map (`x_not_fixed_map`), equality names through the
1779    /// c-block map (`c_map`), and inequality names through the d-block map
1780    /// (`d_map`) — exactly the permutations the adapter applied when it
1781    /// split the problem, so a residual at split index `k` is labeled with
1782    /// the equation the user actually wrote.
1783    ///
1784    /// Returns `None` when the TNLP exposes no names (e.g. presolve, which
1785    /// renumbers rows, declines `get_var_con_metadata`) so callers fall
1786    /// back to index labels rather than mislabeling permuted rows. This is
1787    /// the seam that turns "row 3" into `mass_balance` per Lee et al. (2024,
1788    /// <https://doi.org/10.69997/sct.147875>).
1789    fn split_space_names(&self) -> Option<SplitNames> {
1790        let a = self.adapter.borrow();
1791        let cls = a.classification();
1792
1793        let mut var_meta = MetaData::default();
1794        let mut con_meta = MetaData::default();
1795        if !a
1796            .tnlp()
1797            .borrow_mut()
1798            .get_var_con_metadata(&mut var_meta, &mut con_meta)
1799        {
1800            return None;
1801        }
1802
1803        // Full-space (original TNLP order) name pools. Either may be
1804        // absent — a model can name variables but not constraints, etc.
1805        let var_full = var_meta.strings.get(IDX_NAMES);
1806        let con_full = con_meta.strings.get(IDX_NAMES);
1807        if var_full.is_none() && con_full.is_none() {
1808            return None;
1809        }
1810
1811        // Look a full-space name up safely; `None` for out-of-range or
1812        // empty entries so we degrade to an index label per slot.
1813        let pick = |pool: Option<&Vec<String>>, full_idx: Index| -> Option<String> {
1814            pool.and_then(|v| v.get(full_idx as usize))
1815                .filter(|s| !s.is_empty())
1816                .cloned()
1817        };
1818
1819        let x_var = cls
1820            .x_not_fixed_map
1821            .iter()
1822            .map(|&full_idx| pick(var_full, full_idx))
1823            .collect();
1824        let eq = cls
1825            .c_map
1826            .iter()
1827            .map(|&full_idx| pick(con_full, full_idx))
1828            .collect();
1829        let ineq = cls
1830            .d_map
1831            .iter()
1832            .map(|&full_idx| pick(con_full, full_idx))
1833            .collect();
1834
1835        let names = SplitNames { x_var, eq, ineq };
1836        names.any_present().then_some(names)
1837    }
1838
1839    /// Populate `x` (length `n_x_var`) from the TNLP's starting point,
1840    /// compressed via `x_not_fixed_map`. Mirrors the `init_x` arm of
1841    /// upstream `IpOrigIpoptNLP::GetStartingPoint`.
1842    fn get_starting_x(&mut self, x: &mut dyn Vector) -> bool {
1843        let cls = self.adapter.borrow().classification().clone();
1844        let n_full_x = cls.n_full_x as usize;
1845        let n_full_g = cls.n_full_g as usize;
1846        let mut full_x = vec![0.0; n_full_x];
1847        let mut full_z_l = vec![0.0; n_full_x];
1848        let mut full_z_u = vec![0.0; n_full_x];
1849        let mut full_lambda = vec![0.0; n_full_g];
1850        let ok = {
1851            let a = self.adapter.borrow();
1852            let mut t = a.tnlp().borrow_mut();
1853            t.get_starting_point(StartingPoint {
1854                init_x: true,
1855                x: &mut full_x,
1856                init_z: false,
1857                z_l: &mut full_z_l,
1858                z_u: &mut full_z_u,
1859                init_lambda: false,
1860                lambda: &mut full_lambda,
1861            })
1862        };
1863        if !ok {
1864            return false;
1865        }
1866        let Some(dx) = x.as_any_mut().downcast_mut::<DenseVector>() else {
1867            return false;
1868        };
1869        let xs = dx.values_mut();
1870        for (var_idx, &full_idx) in cls.x_not_fixed_map.iter().enumerate() {
1871            xs[var_idx] = full_x[full_idx as usize];
1872        }
1873        true
1874    }
1875
1876    fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
1877        OrigIpoptNlp::lift_x_to_full(self, x)
1878    }
1879
1880    fn n_full_x(&self) -> Index {
1881        self.adapter.borrow().classification().n_full_x
1882    }
1883
1884    fn n_full_g(&self) -> Index {
1885        self.adapter.borrow().classification().n_full_g
1886    }
1887
1888    fn pack_lambda_for_user(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
1889        let cls = self.adapter.borrow().classification().clone();
1890        OrigIpoptNlp::pack_lambda_for_user(self, y_c, y_d, &cls)
1891    }
1892
1893    fn pack_g_for_user(&self, c: &dyn Vector, d: &dyn Vector) -> Vec<Number> {
1894        let cls = self.adapter.borrow().classification().clone();
1895        let mut g = vec![0.0; cls.n_full_g as usize];
1896        if cls.n_c > 0 {
1897            let Some(dc) = c.as_any().downcast_ref::<DenseVector>() else {
1898                panic!("OrigIpoptNlp expects DenseVector for c");
1899            };
1900            let cs = self.c_scale.borrow();
1901            for (i, &g_idx) in cls.c_map.iter().enumerate() {
1902                let v = dc.expanded_values()[i];
1903                g[g_idx as usize] = match cs.as_ref() {
1904                    Some(s) => v / s[i],
1905                    None => v,
1906                };
1907            }
1908        }
1909        if cls.n_d > 0 {
1910            let Some(dd) = d.as_any().downcast_ref::<DenseVector>() else {
1911                panic!("OrigIpoptNlp expects DenseVector for d");
1912            };
1913            let ds = self.d_scale.borrow();
1914            for (i, &g_idx) in cls.d_map.iter().enumerate() {
1915                let v = dd.expanded_values()[i];
1916                g[g_idx as usize] = match ds.as_ref() {
1917                    Some(s) => v / s[i],
1918                    None => v,
1919                };
1920            }
1921        }
1922        g
1923    }
1924
1925    fn pack_z_l_for_user(&self, z_l: &dyn Vector) -> Vec<Number> {
1926        let cls = self.adapter.borrow().classification().clone();
1927        let mut full = vec![0.0; cls.n_full_x as usize];
1928        if z_l.dim() == 0 {
1929            return full;
1930        }
1931        let Some(dz) = z_l.as_any().downcast_ref::<DenseVector>() else {
1932            panic!("OrigIpoptNlp expects DenseVector for z_l");
1933        };
1934        let vals = dz.expanded_values();
1935        for (k, &var_idx) in cls.x_l_map.iter().enumerate() {
1936            let full_idx = cls.x_not_fixed_map[var_idx as usize] as usize;
1937            full[full_idx] = vals[k];
1938        }
1939        full
1940    }
1941
1942    fn pack_z_u_for_user(&self, z_u: &dyn Vector) -> Vec<Number> {
1943        let cls = self.adapter.borrow().classification().clone();
1944        let mut full = vec![0.0; cls.n_full_x as usize];
1945        if z_u.dim() == 0 {
1946            return full;
1947        }
1948        let Some(dz) = z_u.as_any().downcast_ref::<DenseVector>() else {
1949            panic!("OrigIpoptNlp expects DenseVector for z_u");
1950        };
1951        let vals = dz.expanded_values();
1952        for (k, &var_idx) in cls.x_u_map.iter().enumerate() {
1953            let full_idx = cls.x_not_fixed_map[var_idx as usize] as usize;
1954            full[full_idx] = vals[k];
1955        }
1956        full
1957    }
1958
1959    fn finalize_solution_lambda(&self, y_c: &dyn Vector, y_d: &dyn Vector) -> Vec<Number> {
1960        OrigIpoptNlp::finalize_solution_lambda(self, y_c, y_d)
1961    }
1962
1963    fn finalize_solution_z_l(&self, z_l: &dyn Vector) -> Vec<Number> {
1964        OrigIpoptNlp::finalize_solution_z_l(self, z_l)
1965    }
1966
1967    fn finalize_solution_z_u(&self, z_u: &dyn Vector) -> Vec<Number> {
1968        OrigIpoptNlp::finalize_solution_z_u(self, z_u)
1969    }
1970
1971    fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
1972        let cls = self.adapter.borrow();
1973        let cls = cls.classification();
1974        let f = full_idx as usize;
1975        if f >= cls.full_to_var.len() {
1976            return None;
1977        }
1978        let v = cls.full_to_var[f];
1979        if v < 0 {
1980            None
1981        } else {
1982            Some(v)
1983        }
1984    }
1985
1986    fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
1987        let cls = self.adapter.borrow();
1988        let cls = cls.classification();
1989        cls.c_map
1990            .iter()
1991            .position(|&g_idx| g_idx == full_idx)
1992            .map(|p| p as Index)
1993    }
1994
1995    fn var_x_to_full_x(&self, var_idx: Index) -> Index {
1996        let cls = self.adapter.borrow();
1997        let cls = cls.classification();
1998        cls.x_not_fixed_map[var_idx as usize]
1999    }
2000}
2001
2002// -------------------- Tests --------------------
2003
2004#[cfg(test)]
2005mod tests {
2006    use super::*;
2007    use crate::tnlp::{
2008        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
2009        StartingPoint, TNLP,
2010    };
2011
2012    /// HS071: min x[0]*x[3]*(x[0]+x[1]+x[2]) + x[2]
2013    /// s.t.   x[0]*x[1]*x[2]*x[3] >= 25                (inequality)
2014    ///        x[0]^2 + x[1]^2 + x[2]^2 + x[3]^2 == 40  (equality)
2015    ///        1 <= x[i] <= 5
2016    #[derive(Default)]
2017    struct Hs071 {
2018        eval_f_calls: usize,
2019        eval_grad_f_calls: usize,
2020        eval_g_calls: usize,
2021        eval_jac_g_value_calls: usize,
2022        eval_h_value_calls: usize,
2023    }
2024
2025    impl TNLP for Hs071 {
2026        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2027            Some(NlpInfo {
2028                n: 4,
2029                m: 2,
2030                nnz_jac_g: 8,
2031                nnz_h_lag: 10,
2032                index_style: IndexStyle::C,
2033            })
2034        }
2035        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2036            b.x_l.copy_from_slice(&[1.0; 4]);
2037            b.x_u.copy_from_slice(&[5.0; 4]);
2038            // Constraint 0: 25 <= g0 (inequality, finite lower only)
2039            // Constraint 1: g1 == 40                (equality)
2040            b.g_l.copy_from_slice(&[25.0, 40.0]);
2041            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
2042            true
2043        }
2044        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2045            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
2046            true
2047        }
2048        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
2049            self.eval_f_calls += 1;
2050            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
2051        }
2052        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
2053            self.eval_grad_f_calls += 1;
2054            // df/dx0 = x3*(2x0 + x1 + x2)
2055            // df/dx1 = x0*x3
2056            // df/dx2 = x0*x3 + 1
2057            // df/dx3 = x0*(x0 + x1 + x2)
2058            g[0] = x[3] * (2.0 * x[0] + x[1] + x[2]);
2059            g[1] = x[0] * x[3];
2060            g[2] = x[0] * x[3] + 1.0;
2061            g[3] = x[0] * (x[0] + x[1] + x[2]);
2062            true
2063        }
2064        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
2065            self.eval_g_calls += 1;
2066            // g0 = x0*x1*x2*x3 (>=25)
2067            // g1 = x0^2 + x1^2 + x2^2 + x3^2 (==40)
2068            g[0] = x[0] * x[1] * x[2] * x[3];
2069            g[1] = x[0] * x[0] + x[1] * x[1] + x[2] * x[2] + x[3] * x[3];
2070            true
2071        }
2072        fn eval_jac_g(
2073            &mut self,
2074            x: Option<&[Number]>,
2075            _new_x: bool,
2076            mode: SparsityRequest<'_>,
2077        ) -> bool {
2078            match mode {
2079                SparsityRequest::Structure { irow, jcol } => {
2080                    // Dense 2x4: row-major (g0 over x0..x3, then g1 over x0..x3).
2081                    irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
2082                    jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
2083                }
2084                SparsityRequest::Values { values } => {
2085                    self.eval_jac_g_value_calls += 1;
2086                    let x = x.expect("eval_jac_g(Values) without x");
2087                    // d g0 / d x_j
2088                    values[0] = x[1] * x[2] * x[3];
2089                    values[1] = x[0] * x[2] * x[3];
2090                    values[2] = x[0] * x[1] * x[3];
2091                    values[3] = x[0] * x[1] * x[2];
2092                    // d g1 / d x_j
2093                    values[4] = 2.0 * x[0];
2094                    values[5] = 2.0 * x[1];
2095                    values[6] = 2.0 * x[2];
2096                    values[7] = 2.0 * x[3];
2097                }
2098            }
2099            true
2100        }
2101        fn eval_h(
2102            &mut self,
2103            x: Option<&[Number]>,
2104            _new_x: bool,
2105            obj_factor: Number,
2106            lambda: Option<&[Number]>,
2107            _new_lambda: bool,
2108            mode: SparsityRequest<'_>,
2109        ) -> bool {
2110            // Dense lower triangle of 4x4 = 10 entries:
2111            // (0,0) (1,0) (1,1) (2,0) (2,1) (2,2) (3,0) (3,1) (3,2) (3,3)
2112            match mode {
2113                SparsityRequest::Structure { irow, jcol } => {
2114                    irow.copy_from_slice(&[0, 1, 1, 2, 2, 2, 3, 3, 3, 3]);
2115                    jcol.copy_from_slice(&[0, 0, 1, 0, 1, 2, 0, 1, 2, 3]);
2116                }
2117                SparsityRequest::Values { values } => {
2118                    self.eval_h_value_calls += 1;
2119                    let x = x.expect("eval_h(Values) without x");
2120                    let lam = lambda.expect("eval_h(Values) without lambda");
2121                    let of = obj_factor;
2122                    // Hessian of objective:
2123                    //   d2f/dx0^2 = 2*x3
2124                    //   d2f/dx0dx1 = x3,  d2f/dx0dx2 = x3,
2125                    //   d2f/dx0dx3 = 2*x0+x1+x2
2126                    //   d2f/dx1dx3 = x0,  d2f/dx2dx3 = x0
2127                    // Hessian of g0 = x0*x1*x2*x3:
2128                    //   d2/dx0dx1 = x2*x3, d2/dx0dx2 = x1*x3, d2/dx0dx3 = x1*x2
2129                    //   d2/dx1dx2 = x0*x3, d2/dx1dx3 = x0*x2, d2/dx2dx3 = x0*x1
2130                    // Hessian of g1 = sum x_i^2: 2*I.
2131                    let l0 = lam[0];
2132                    let l1 = lam[1];
2133                    values[0] = of * (2.0 * x[3]) + l1 * 2.0; // (0,0)
2134                    values[1] = of * x[3] + l0 * (x[2] * x[3]); // (1,0)
2135                    values[2] = l1 * 2.0; // (1,1)
2136                    values[3] = of * x[3] + l0 * (x[1] * x[3]); // (2,0)
2137                    values[4] = l0 * (x[0] * x[3]); // (2,1)
2138                    values[5] = l1 * 2.0; // (2,2)
2139                    values[6] = of * (2.0 * x[0] + x[1] + x[2]) + l0 * (x[1] * x[2]); // (3,0)
2140                    values[7] = of * x[0] + l0 * (x[0] * x[2]); // (3,1)
2141                    values[8] = of * x[0] + l0 * (x[0] * x[1]); // (3,2)
2142                    values[9] = l1 * 2.0; // (3,3)
2143                }
2144            }
2145            true
2146        }
2147        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
2148    }
2149
2150    fn build_orig_nlp() -> (Rc<RefCell<TNLPAdapter>>, OrigIpoptNlp) {
2151        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Hs071::default()));
2152        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2153        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2154        (adapter, nlp)
2155    }
2156
2157    fn dense_x(values: &[Number], space: &Rc<DenseVectorSpace>) -> DenseVector {
2158        let mut v = space.make_new_dense();
2159        v.values_mut().copy_from_slice(values);
2160        v
2161    }
2162
2163    #[test]
2164    fn dimensions_match_classification() {
2165        let (_, nlp) = build_orig_nlp();
2166        // HS071: 4 vars (none fixed), 1 equality, 1 inequality.
2167        assert_eq!(nlp.n(), 4);
2168        assert_eq!(nlp.m_eq(), 1);
2169        assert_eq!(nlp.m_ineq(), 1);
2170        // 4 entries of jac_g go to c-row (g1), 4 go to d-row (g0).
2171        assert_eq!(nlp.jac_c_space().nonzeros(), 4);
2172        assert_eq!(nlp.jac_d_space().nonzeros(), 4);
2173        // Hessian sparsity comes through.
2174        assert_eq!(nlp.h_space().unwrap().nonzeros(), 10);
2175        // Bounds: all 4 x's bounded both sides; 1 ineq with finite lower only.
2176        assert_eq!(nlp.x_l().dim(), 4);
2177        assert_eq!(nlp.x_u().dim(), 4);
2178        assert_eq!(nlp.d_l().dim(), 1);
2179        assert_eq!(nlp.d_u().dim(), 0);
2180    }
2181
2182    #[test]
2183    fn eval_f_at_starting_point() {
2184        let (_, mut nlp) = build_orig_nlp();
2185        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2186        // f = 1*1*(1+5+5) + 5 = 11 + 5 = 16
2187        assert_eq!(nlp.eval_f(&x), 16.0);
2188        assert_eq!(nlp.f_evals(), 1);
2189    }
2190
2191    #[test]
2192    fn eval_grad_f_at_starting_point() {
2193        let (_, mut nlp) = build_orig_nlp();
2194        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2195        let mut g = nlp.x_space().make_new_dense();
2196        nlp.eval_grad_f(&x, &mut g);
2197        // df/dx0 = 1*(2 + 5 + 5) = 12
2198        // df/dx1 = 1*1 = 1
2199        // df/dx2 = 1*1 + 1 = 2
2200        // df/dx3 = 1*(1 + 5 + 5) = 11
2201        assert_eq!(g.values(), &[12.0, 1.0, 2.0, 11.0]);
2202        assert_eq!(nlp.grad_f_evals(), 1);
2203    }
2204
2205    #[test]
2206    fn eval_c_returns_equality_residual() {
2207        let (_, mut nlp) = build_orig_nlp();
2208        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2209        let mut c = nlp.c_space().make_new_dense();
2210        nlp.eval_c(&x, &mut c);
2211        // g1 = 1 + 25 + 25 + 1 = 52; residual = 52 - 40 = 12.
2212        assert_eq!(c.values(), &[12.0]);
2213        assert_eq!(nlp.c_evals(), 1);
2214    }
2215
2216    #[test]
2217    fn eval_d_returns_inequality_value_unshifted() {
2218        let (_, mut nlp) = build_orig_nlp();
2219        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2220        let mut d = nlp.d_space().make_new_dense();
2221        nlp.eval_d(&x, &mut d);
2222        // g0 = 1*5*5*1 = 25.
2223        assert_eq!(d.values(), &[25.0]);
2224        assert_eq!(nlp.d_evals(), 1);
2225    }
2226
2227    #[test]
2228    fn cache_returns_without_re_eval() {
2229        let (_, mut nlp) = build_orig_nlp();
2230        let mut x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2231        let f1 = nlp.eval_f(&x);
2232        let f2 = nlp.eval_f(&x);
2233        assert_eq!(f1, f2);
2234        assert_eq!(nlp.f_evals(), 1, "second call must be served from cache");
2235        // Bumping x's tag (i.e. mutating it) should invalidate the cache.
2236        x.values_mut()[0] = 1.0; // values_mut bumps the cache.
2237        let _ = nlp.eval_f(&x);
2238        assert_eq!(nlp.f_evals(), 2);
2239    }
2240
2241    #[test]
2242    fn jac_c_picks_only_equality_rows() {
2243        let (_, mut nlp) = build_orig_nlp();
2244        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2245        let m = nlp.eval_jac_c(&x);
2246        let g = m
2247            .as_any()
2248            .downcast_ref::<GenTMatrix>()
2249            .expect("jac_c is a GenTMatrix");
2250        // Equality is g1: dg1/dxj = 2*x_j.
2251        assert_eq!(g.values(), &[2.0, 10.0, 10.0, 2.0]);
2252        // 1-based row should all be 1 (the single equality row).
2253        assert_eq!(g.irows(), &[1, 1, 1, 1]);
2254        assert_eq!(g.jcols(), &[1, 2, 3, 4]);
2255    }
2256
2257    #[test]
2258    fn jac_d_picks_only_inequality_rows() {
2259        let (_, mut nlp) = build_orig_nlp();
2260        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2261        let m = nlp.eval_jac_d(&x);
2262        let g = m
2263            .as_any()
2264            .downcast_ref::<GenTMatrix>()
2265            .expect("jac_d is a GenTMatrix");
2266        // Inequality is g0: d/dxj of x0*x1*x2*x3 at (1,5,5,1).
2267        // d/dx0 = 5*5*1 = 25, d/dx1 = 1*5*1 = 5, d/dx2 = 1*5*1 = 5, d/dx3 = 1*5*5 = 25.
2268        assert_eq!(g.values(), &[25.0, 5.0, 5.0, 25.0]);
2269    }
2270
2271    #[test]
2272    fn starting_point_is_compressed_into_x_var() {
2273        let (_, mut nlp) = build_orig_nlp();
2274        let mut x = nlp.x_space().make_new_dense();
2275        let mut yc = nlp.c_space().make_new_dense();
2276        let mut yd = nlp.d_space().make_new_dense();
2277        let mut zl = nlp.x_l_space().make_new_dense();
2278        let mut zu = nlp.x_u_space().make_new_dense();
2279        let ok = nlp.initialize_starting_point(
2280            &mut x, true, &mut yc, false, &mut yd, false, &mut zl, false, &mut zu, false,
2281        );
2282        assert!(ok);
2283        assert_eq!(x.values(), &[1.0, 5.0, 5.0, 1.0]);
2284    }
2285
2286    /// Two-variable TNLP with `x[0]` fixed at 7.0 (`x_l == x_u`) and
2287    /// one equality on `x[1]`. Exercises the index-mapping methods on
2288    /// the `IpoptNlp` trait that are used by `pounce_sens` to support
2289    /// `.nl` files with fixed variables.
2290    struct OneFixedOneFree;
2291    impl TNLP for OneFixedOneFree {
2292        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2293            Some(NlpInfo {
2294                n: 2,
2295                m: 1,
2296                nnz_jac_g: 1,
2297                nnz_h_lag: 0,
2298                index_style: IndexStyle::C,
2299            })
2300        }
2301        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2302            b.x_l[0] = 7.0;
2303            b.x_u[0] = 7.0; // fixed
2304            b.x_l[1] = -1.0e19;
2305            b.x_u[1] = 1.0e19;
2306            b.g_l[0] = 0.0;
2307            b.g_u[0] = 0.0; // equality
2308            true
2309        }
2310        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2311            sp.x[0] = 7.0;
2312            sp.x[1] = 0.5;
2313            true
2314        }
2315        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2316            Some(x[1])
2317        }
2318        fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
2319            g[0] = 0.0;
2320            g[1] = 1.0;
2321            true
2322        }
2323        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2324            g[0] = x[1];
2325            true
2326        }
2327        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2328            match m {
2329                SparsityRequest::Structure { irow, jcol } => {
2330                    irow[0] = 0;
2331                    jcol[0] = 1;
2332                }
2333                SparsityRequest::Values { values } => values[0] = 1.0,
2334            }
2335            true
2336        }
2337        fn eval_h(
2338            &mut self,
2339            _: Option<&[Number]>,
2340            _: bool,
2341            _: Number,
2342            _: Option<&[Number]>,
2343            _: bool,
2344            _: SparsityRequest<'_>,
2345        ) -> bool {
2346            true
2347        }
2348        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2349    }
2350
2351    #[test]
2352    fn ipopt_nlp_index_mapping_methods_handle_fixed_var() {
2353        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedOneFree));
2354        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2355        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2356
2357        // Sanity: classification trimmed x[0] (fixed) from var-x space.
2358        assert_eq!(nlp.n_full_x(), 2);
2359        assert_eq!(nlp.n(), 1);
2360
2361        // full_x_to_var_x: x[0] is fixed → None; x[1] → var idx 0.
2362        let nlp_dyn: &dyn crate::ipopt_nlp::IpoptNlp = &nlp;
2363        assert_eq!(nlp_dyn.full_x_to_var_x(0), None);
2364        assert_eq!(nlp_dyn.full_x_to_var_x(1), Some(0));
2365
2366        // var_x_to_full_x: var 0 → full 1.
2367        assert_eq!(nlp_dyn.var_x_to_full_x(0), 1);
2368
2369        // full_g_to_c_block: the one g is an equality → c-block 0.
2370        assert_eq!(nlp_dyn.full_g_to_c_block(0), Some(0));
2371
2372        // lift_x_to_full inflates a compressed [v_0] back to [7.0, v_0].
2373        let mut x_var = nlp.x_space().make_new_dense();
2374        x_var.values_mut()[0] = 0.5;
2375        let lifted = nlp_dyn.lift_x_to_full(&x_var);
2376        assert_eq!(lifted, vec![7.0, 0.5]);
2377    }
2378
2379    /// `OneFixedOneFree` plus `idx_names` metadata — used to check the
2380    /// split-space name projection threads names through the fixed-var
2381    /// and c/d-split permutations.
2382    struct NamedFixedOneFree;
2383    impl TNLP for NamedFixedOneFree {
2384        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2385            OneFixedOneFree.get_nlp_info()
2386        }
2387        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2388            OneFixedOneFree.get_bounds_info(b)
2389        }
2390        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2391            OneFixedOneFree.get_starting_point(sp)
2392        }
2393        fn eval_f(&mut self, x: &[Number], n: bool) -> Option<Number> {
2394            OneFixedOneFree.eval_f(x, n)
2395        }
2396        fn eval_grad_f(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
2397            OneFixedOneFree.eval_grad_f(x, n, g)
2398        }
2399        fn eval_g(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
2400            OneFixedOneFree.eval_g(x, n, g)
2401        }
2402        fn eval_jac_g(&mut self, x: Option<&[Number]>, n: bool, m: SparsityRequest<'_>) -> bool {
2403            OneFixedOneFree.eval_jac_g(x, n, m)
2404        }
2405        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2406        fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
2407            var.strings.insert(
2408                IDX_NAMES.to_string(),
2409                vec!["fixed_x".to_string(), "free_x".to_string()],
2410            );
2411            con.strings
2412                .insert(IDX_NAMES.to_string(), vec!["balance".to_string()]);
2413            true
2414        }
2415    }
2416
2417    #[test]
2418    fn split_space_names_threads_through_fixed_var_and_cd_split() {
2419        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(NamedFixedOneFree));
2420        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2421        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2422
2423        let names = nlp.split_space_names().expect("names present");
2424        // x[0] (fixed) dropped; var-x 0 is full-x 1 = "free_x".
2425        assert_eq!(names.x_var, vec![Some("free_x".to_string())]);
2426        // The single g is an equality → c-block 0 = "balance".
2427        assert_eq!(names.eq, vec![Some("balance".to_string())]);
2428        // No inequalities.
2429        assert!(names.ineq.is_empty());
2430        assert!(names.any_present());
2431    }
2432
2433    #[test]
2434    fn split_space_names_none_when_tnlp_declines() {
2435        // OneFixedOneFree does not implement get_var_con_metadata.
2436        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedOneFree));
2437        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2438        let nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2439        assert!(nlp.split_space_names().is_none());
2440    }
2441
2442    /// Regression: a TNLP with `x[0]` fixed and `nnz_h_lag = 1` whose
2443    /// only Hessian entry is (0,0). After fixed-var filtering kept = 0
2444    /// but `nnz_h_lag_full = 1`, which used to hit the broken
2445    /// `h_entry_in_full.is_empty()` fast path and panic in
2446    /// `copy_from_slice`.
2447    struct FixedOnlyHess;
2448    impl TNLP for FixedOnlyHess {
2449        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2450            Some(NlpInfo {
2451                n: 2,
2452                m: 1,
2453                nnz_jac_g: 1,
2454                nnz_h_lag: 1,
2455                index_style: IndexStyle::C,
2456            })
2457        }
2458        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2459            b.x_l[0] = 7.0;
2460            b.x_u[0] = 7.0; // fixed
2461            b.x_l[1] = -1.0e19;
2462            b.x_u[1] = 1.0e19;
2463            b.g_l[0] = 0.0;
2464            b.g_u[0] = 0.0;
2465            true
2466        }
2467        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2468            sp.x[0] = 7.0;
2469            sp.x[1] = 0.5;
2470            true
2471        }
2472        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2473            Some(0.5 * x[0] * x[0] + x[1])
2474        }
2475        fn eval_grad_f(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2476            g[0] = x[0];
2477            g[1] = 1.0;
2478            true
2479        }
2480        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2481            g[0] = x[1];
2482            true
2483        }
2484        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2485            match m {
2486                SparsityRequest::Structure { irow, jcol } => {
2487                    irow[0] = 0;
2488                    jcol[0] = 1;
2489                }
2490                SparsityRequest::Values { values } => values[0] = 1.0,
2491            }
2492            true
2493        }
2494        fn eval_h(
2495            &mut self,
2496            _: Option<&[Number]>,
2497            _: bool,
2498            obj_factor: Number,
2499            _: Option<&[Number]>,
2500            _: bool,
2501            m: SparsityRequest<'_>,
2502        ) -> bool {
2503            match m {
2504                SparsityRequest::Structure { irow, jcol } => {
2505                    irow[0] = 0;
2506                    jcol[0] = 0;
2507                }
2508                SparsityRequest::Values { values } => values[0] = obj_factor,
2509            }
2510            true
2511        }
2512        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2513    }
2514
2515    /// One variable with a single one-sided inequality whose Jacobian
2516    /// magnitude trips `nlp_scaling_max_gradient` (default 100): coeff
2517    /// 1000, bound `lo = 4e6`. After gradient-based scaling the
2518    /// `d_scale` for this row is `100/1000 = 0.1`, so the algorithm
2519    /// sees `d(x) = 0.1 * 1000 * x`. The bound must be scaled to
2520    /// `0.1 * 4e6 = 4e5` to match — otherwise the algorithm reads a
2521    /// 10x-too-large lower bound and reports phantom infeasibility
2522    /// (gh#54).
2523    struct OneIneqLargeOffset;
2524    impl TNLP for OneIneqLargeOffset {
2525        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2526            Some(NlpInfo {
2527                n: 1,
2528                m: 1,
2529                nnz_jac_g: 1,
2530                nnz_h_lag: 0,
2531                index_style: IndexStyle::C,
2532            })
2533        }
2534        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2535            b.x_l[0] = -1.0e19;
2536            b.x_u[0] = 1.0e19;
2537            b.g_l[0] = 4.0e6;
2538            b.g_u[0] = 2.0e19;
2539            true
2540        }
2541        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2542            sp.x[0] = 5000.0;
2543            true
2544        }
2545        fn eval_f(&mut self, _: &[Number], _: bool) -> Option<Number> {
2546            Some(0.0)
2547        }
2548        fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
2549            g[0] = 0.0;
2550            true
2551        }
2552        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2553            g[0] = 1000.0 * x[0];
2554            true
2555        }
2556        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2557            match m {
2558                SparsityRequest::Structure { irow, jcol } => {
2559                    irow[0] = 0;
2560                    jcol[0] = 0;
2561                }
2562                SparsityRequest::Values { values } => values[0] = 1000.0,
2563            }
2564            true
2565        }
2566        fn eval_h(
2567            &mut self,
2568            _: Option<&[Number]>,
2569            _: bool,
2570            _: Number,
2571            _: Option<&[Number]>,
2572            _: bool,
2573            _: SparsityRequest<'_>,
2574        ) -> bool {
2575            true
2576        }
2577        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2578    }
2579
2580    #[test]
2581    fn gradient_based_scaling_scales_d_l_and_d_u() {
2582        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneIneqLargeOffset));
2583        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2584        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2585
2586        // Pre-scaling: d_l carries the raw user bound 4e6.
2587        assert_eq!(nlp.d_l().dim(), 1);
2588        let pre = nlp
2589            .d_l()
2590            .as_any()
2591            .downcast_ref::<DenseVector>()
2592            .unwrap()
2593            .values()[0];
2594        assert_eq!(pre, 4.0e6);
2595
2596        nlp.determine_scaling_from_starting_point(
2597            ScalingMethod::GradientBased,
2598            100.0,
2599            1e-8,
2600            0.0,
2601            0.0,
2602        );
2603
2604        // d_scale = 100 / 1000 = 0.1; bound must scale in step.
2605        let post = nlp
2606            .d_l()
2607            .as_any()
2608            .downcast_ref::<DenseVector>()
2609            .unwrap()
2610            .values()[0];
2611        assert!(
2612            (post - 4.0e5).abs() < 1e-9,
2613            "d_l should be scaled by d_scale=0.1; got {}",
2614            post
2615        );
2616
2617        // And d(x) at the starting point must agree with the scaled
2618        // bound: d(5000) = 0.1 * 1000 * 5000 = 5e5 > 4e5, so feasible.
2619        let x = dense_x(&[5000.0], nlp.x_space());
2620        let mut d = nlp.d_space().make_new_dense();
2621        nlp.eval_d(&x, &mut d);
2622        assert!(
2623            (d.values()[0] - 5.0e5).abs() < 1e-6,
2624            "scaled d(x) mismatch; got {}",
2625            d.values()[0]
2626        );
2627        assert!(
2628            d.values()[0] >= post,
2629            "starting point must be feasible in scaled space"
2630        );
2631    }
2632
2633    /// Same fixture as [`OneIneqLargeOffset`] but with a non-zero
2634    /// objective gradient (10), so we can verify that
2635    /// `nlp_scaling_obj_target_gradient` pins the scaled gradient
2636    /// ∞-norm exactly to the requested value (independent of the
2637    /// `max_gradient` cutoff).
2638    struct OneIneqWithObj;
2639    impl TNLP for OneIneqWithObj {
2640        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2641            Some(NlpInfo {
2642                n: 1,
2643                m: 1,
2644                nnz_jac_g: 1,
2645                nnz_h_lag: 0,
2646                index_style: IndexStyle::C,
2647            })
2648        }
2649        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2650            b.x_l[0] = -1.0e19;
2651            b.x_u[0] = 1.0e19;
2652            b.g_l[0] = 4.0e6;
2653            b.g_u[0] = 2.0e19;
2654            true
2655        }
2656        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2657            sp.x[0] = 5000.0;
2658            true
2659        }
2660        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2661            Some(10.0 * x[0])
2662        }
2663        fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
2664            g[0] = 10.0;
2665            true
2666        }
2667        fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2668            g[0] = 1000.0 * x[0];
2669            true
2670        }
2671        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, m: SparsityRequest<'_>) -> bool {
2672            match m {
2673                SparsityRequest::Structure { irow, jcol } => {
2674                    irow[0] = 0;
2675                    jcol[0] = 0;
2676                }
2677                SparsityRequest::Values { values } => values[0] = 1000.0,
2678            }
2679            true
2680        }
2681        fn eval_h(
2682            &mut self,
2683            _: Option<&[Number]>,
2684            _: bool,
2685            _: Number,
2686            _: Option<&[Number]>,
2687            _: bool,
2688            _: SparsityRequest<'_>,
2689        ) -> bool {
2690            true
2691        }
2692        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2693    }
2694
2695    #[test]
2696    fn obj_target_gradient_pins_obj_scale() {
2697        // grad_f = [10], so the default gradient-based path (max_grad=100,
2698        // 10 < cutoff) does NOT scale the objective: df = 1.
2699        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneIneqWithObj));
2700        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2701        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2702        nlp.determine_scaling_from_starting_point(
2703            ScalingMethod::GradientBased,
2704            100.0,
2705            1e-8,
2706            0.0, // no target → use cutoff path
2707            0.0,
2708        );
2709        assert!(
2710            (nlp.obj_scale_factor() - 1.0).abs() < 1e-12,
2711            "no-target path leaves df=1 when grad < cutoff; got {}",
2712            nlp.obj_scale_factor()
2713        );
2714
2715        // With obj_target_gradient = 1.0 the scaled gradient ∞-norm
2716        // must be exactly 1, i.e. df = 1.0 / 10.0 = 0.1.
2717        let tnlp2: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneIneqWithObj));
2718        let adapter2 = Rc::new(RefCell::new(TNLPAdapter::new(tnlp2).unwrap()));
2719        let mut nlp2 = OrigIpoptNlp::new(Rc::clone(&adapter2), Rc::new(NoScaling)).unwrap();
2720        nlp2.determine_scaling_from_starting_point(
2721            ScalingMethod::GradientBased,
2722            100.0,
2723            1e-8,
2724            1.0,
2725            0.0,
2726        );
2727        assert!(
2728            (nlp2.obj_scale_factor() - 0.1).abs() < 1e-12,
2729            "target_gradient=1, max_grad_f=10 → df=0.1; got {}",
2730            nlp2.obj_scale_factor()
2731        );
2732    }
2733
2734    /// Regression (flosp2hm): gradient-based scaling must sample the
2735    /// objective gradient at the point the algorithm actually operates
2736    /// on — i.e. with fixed variables (`x_l == x_u`) lifted to their
2737    /// fixed value — not at the raw `x0` returned by `get_starting_point`.
2738    /// Here `x[1]` is fixed at 1000 but the starting point places it at 0,
2739    /// and the only free-variable gradient is `df/dx0 = x[1]`. Sampling at
2740    /// the raw `x0` gives `max_grad_f = 0` (df stays 1.0, no scaling);
2741    /// lifting `x[1]→1000` gives `max_grad_f = 1000`, so df = 100/1000 = 0.1.
2742    /// Pre-fix this left df=1 and stalled flosp2hm at max-iter.
2743    struct FixedVarShiftsObjGrad;
2744    impl TNLP for FixedVarShiftsObjGrad {
2745        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2746            Some(NlpInfo {
2747                n: 2,
2748                m: 0,
2749                nnz_jac_g: 0,
2750                nnz_h_lag: 0,
2751                index_style: IndexStyle::C,
2752            })
2753        }
2754        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2755            b.x_l[0] = -1.0e19;
2756            b.x_u[0] = 1.0e19;
2757            b.x_l[1] = 1000.0;
2758            b.x_u[1] = 1000.0; // fixed at 1000
2759            true
2760        }
2761        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2762            sp.x[0] = 1.0;
2763            sp.x[1] = 0.0; // deliberately NOT the fixed value
2764            true
2765        }
2766        fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
2767            Some(x[0] * x[1])
2768        }
2769        fn eval_grad_f(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
2770            g[0] = x[1];
2771            g[1] = x[0];
2772            true
2773        }
2774        fn eval_g(&mut self, _: &[Number], _: bool, _: &mut [Number]) -> bool {
2775            true
2776        }
2777        fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, _: SparsityRequest<'_>) -> bool {
2778            true
2779        }
2780        fn eval_h(
2781            &mut self,
2782            _: Option<&[Number]>,
2783            _: bool,
2784            _: Number,
2785            _: Option<&[Number]>,
2786            _: bool,
2787            _: SparsityRequest<'_>,
2788        ) -> bool {
2789            true
2790        }
2791        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2792    }
2793
2794    #[test]
2795    fn gradient_scaling_lifts_fixed_vars_to_their_value() {
2796        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FixedVarShiftsObjGrad));
2797        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2798        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2799
2800        // Sanity: x[1] is fixed out of the var-x space, fixed value 1000.
2801        assert_eq!(nlp.n_full_x(), 2);
2802        assert_eq!(nlp.n(), 1);
2803
2804        nlp.determine_scaling_from_starting_point(
2805            ScalingMethod::GradientBased,
2806            100.0,
2807            1e-8,
2808            0.0,
2809            0.0,
2810        );
2811
2812        // Lifted gradient ∞-norm over free vars is |df/dx0| = x[1] = 1000,
2813        // so df = 100/1000 = 0.1. Sampling at the raw x0 (x[1]=0) would
2814        // give 0 and leave df=1.0 (the pre-fix bug).
2815        assert!(
2816            (nlp.obj_scale_factor() - 0.1).abs() < 1e-12,
2817            "fixed var must be lifted before scaling; expected df=0.1, got {}",
2818            nlp.obj_scale_factor()
2819        );
2820    }
2821
2822    #[test]
2823    fn constr_target_gradient_overrides_cutoff_and_clamp() {
2824        // Jacobian row max = 1000. Default gradient-based: cutoff 100
2825        // fires, dc = min(1, 100/1000) = 0.1. With
2826        // constr_target_gradient = 50 → dc = 50/1000 = 0.05 (no clamp
2827        // at 1, no cutoff check).
2828        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneIneqLargeOffset));
2829        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2830        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2831        nlp.determine_scaling_from_starting_point(
2832            ScalingMethod::GradientBased,
2833            100.0,
2834            1e-8,
2835            0.0,
2836            50.0,
2837        );
2838        let x = dense_x(&[5000.0], nlp.x_space());
2839        let mut d = nlp.d_space().make_new_dense();
2840        nlp.eval_d(&x, &mut d);
2841        // scaled d(x) = 0.05 * 1000 * 5000 = 2.5e5.
2842        assert!(
2843            (d.values()[0] - 2.5e5).abs() < 1e-6,
2844            "constr target=50 → dd=0.05; scaled d(5000)=2.5e5, got {}",
2845            d.values()[0]
2846        );
2847    }
2848
2849    /// User-supplied TNLP that returns a per-constraint scaling vector
2850    /// via `get_scaling_parameters`. Constraint 0 is the equality (g1);
2851    /// constraint 1 is the inequality (g0). We reuse the HS071 fixture
2852    /// so the c/d split is well-defined.
2853    struct Hs071UserScaled;
2854    impl TNLP for Hs071UserScaled {
2855        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2856            Hs071::default().get_nlp_info()
2857        }
2858        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2859            Hs071::default().get_bounds_info(b)
2860        }
2861        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2862            Hs071::default().get_starting_point(sp)
2863        }
2864        fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
2865            Hs071::default().eval_f(x, new_x)
2866        }
2867        fn eval_grad_f(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2868            Hs071::default().eval_grad_f(x, new_x, g)
2869        }
2870        fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2871            Hs071::default().eval_g(x, new_x, g)
2872        }
2873        fn eval_jac_g(
2874            &mut self,
2875            x: Option<&[Number]>,
2876            new_x: bool,
2877            mode: SparsityRequest<'_>,
2878        ) -> bool {
2879            Hs071::default().eval_jac_g(x, new_x, mode)
2880        }
2881        fn eval_h(
2882            &mut self,
2883            x: Option<&[Number]>,
2884            new_x: bool,
2885            obj_factor: Number,
2886            lambda: Option<&[Number]>,
2887            new_lambda: bool,
2888            mode: SparsityRequest<'_>,
2889        ) -> bool {
2890            Hs071::default().eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
2891        }
2892        fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
2893            *req.obj_scaling = 2.0;
2894            *req.use_x_scaling = false;
2895            *req.use_g_scaling = true;
2896            // HS071 g layout: g[0] = inequality, g[1] = equality.
2897            req.g_scaling[0] = 0.5;
2898            req.g_scaling[1] = 0.25;
2899            true
2900        }
2901        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
2902    }
2903
2904    #[test]
2905    fn user_scaling_dispatch_applies_obj_and_g_scaling() {
2906        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Hs071UserScaled));
2907        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
2908        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
2909        nlp.determine_scaling_from_starting_point(
2910            ScalingMethod::UserScaling,
2911            100.0,
2912            1e-8,
2913            0.0,
2914            0.0,
2915        );
2916
2917        // Objective scaling: 2.0 (no automatic floor needed since
2918        // user supplied a normal-sized factor).
2919        assert!(
2920            (nlp.obj_scale_factor() - 2.0).abs() < 1e-12,
2921            "user obj_scaling=2.0 should be installed; got {}",
2922            nlp.obj_scale_factor()
2923        );
2924
2925        // Equality row (g1) gets g_scaling[1] = 0.25 → c-scaled
2926        // residual is 0.25× the unscaled one. Compute c at the
2927        // starting point.
2928        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
2929        let mut c = nlp.c_space().make_new_dense();
2930        nlp.eval_c(&x, &mut c);
2931        // Unscaled: g1 = 1+25+25+1 = 52, residual = 52-40 = 12.
2932        // Scaled: 0.25 * 12 = 3.0.
2933        assert!(
2934            (c.values()[0] - 3.0).abs() < 1e-9,
2935            "user g_scaling=0.25 on equality → c=3.0; got {}",
2936            c.values()[0]
2937        );
2938
2939        // Inequality row (g0) gets g_scaling[0] = 0.5 → d = 0.5 *
2940        // 1*5*5*1 = 12.5.
2941        let mut d = nlp.d_space().make_new_dense();
2942        nlp.eval_d(&x, &mut d);
2943        assert!(
2944            (d.values()[0] - 12.5).abs() < 1e-9,
2945            "user g_scaling=0.5 on inequality → d=12.5; got {}",
2946            d.values()[0]
2947        );
2948
2949        // And d_l must have been brought along: the user lower bound
2950        // on g0 is 25 (HS071); scaled by 0.5 → 12.5.
2951        let post_d_l = nlp
2952            .d_l()
2953            .as_any()
2954            .downcast_ref::<DenseVector>()
2955            .unwrap()
2956            .values()[0];
2957        assert!(
2958            (post_d_l - 12.5).abs() < 1e-9,
2959            "d_l scaled in step: got {}",
2960            post_d_l
2961        );
2962    }
2963
2964    /// TNLP whose `get_scaling_parameters` returns false — selecting
2965    /// `UserScaling` must fall back to no automatic scaling (matches
2966    /// upstream behavior).
2967    struct Hs071DeclinesScaling;
2968    impl TNLP for Hs071DeclinesScaling {
2969        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2970            Hs071::default().get_nlp_info()
2971        }
2972        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2973            Hs071::default().get_bounds_info(b)
2974        }
2975        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2976            Hs071::default().get_starting_point(sp)
2977        }
2978        fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
2979            Hs071::default().eval_f(x, new_x)
2980        }
2981        fn eval_grad_f(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2982            Hs071::default().eval_grad_f(x, new_x, g)
2983        }
2984        fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2985            Hs071::default().eval_g(x, new_x, g)
2986        }
2987        fn eval_jac_g(
2988            &mut self,
2989            x: Option<&[Number]>,
2990            new_x: bool,
2991            mode: SparsityRequest<'_>,
2992        ) -> bool {
2993            Hs071::default().eval_jac_g(x, new_x, mode)
2994        }
2995        fn eval_h(
2996            &mut self,
2997            x: Option<&[Number]>,
2998            new_x: bool,
2999            obj_factor: Number,
3000            lambda: Option<&[Number]>,
3001            new_lambda: bool,
3002            mode: SparsityRequest<'_>,
3003        ) -> bool {
3004            Hs071::default().eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
3005        }
3006        fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
3007    }
3008
3009    #[test]
3010    fn user_scaling_falls_back_when_tnlp_declines() {
3011        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Hs071DeclinesScaling));
3012        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
3013        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
3014        nlp.determine_scaling_from_starting_point(
3015            ScalingMethod::UserScaling,
3016            100.0,
3017            1e-8,
3018            0.0,
3019            0.0,
3020        );
3021        // No automatic scaling installed: obj_scale_factor = 1.0, c/d
3022        // unscaled.
3023        assert!((nlp.obj_scale_factor() - 1.0).abs() < 1e-12);
3024        let x = dense_x(&[1.0, 5.0, 5.0, 1.0], nlp.x_space());
3025        let mut c = nlp.c_space().make_new_dense();
3026        nlp.eval_c(&x, &mut c);
3027        assert_eq!(c.values(), &[12.0], "unscaled equality residual");
3028    }
3029
3030    #[test]
3031    fn eval_h_with_all_entries_on_fixed_var_does_not_panic() {
3032        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FixedOnlyHess));
3033        let adapter = Rc::new(RefCell::new(TNLPAdapter::new(tnlp).unwrap()));
3034        let mut nlp = OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)).unwrap();
3035
3036        // After filtering, the kept Hessian over var-x has 0 nonzeros,
3037        // while the user's full Hessian has 1.
3038        assert_eq!(nlp.h_space().unwrap().nonzeros(), 0);
3039
3040        let x = dense_x(&[0.5], &nlp.x_space().clone());
3041        let yc = dense_x(&[0.0], &nlp.c_space().clone());
3042        let yd = nlp.d_space().make_new_dense();
3043        let h = nlp.eval_h(&x, 1.0, &yc, &yd);
3044        assert_eq!(h.n_rows(), 1);
3045    }
3046}