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