Skip to main content

pounce_nlp/
orig_ipopt_nlp.rs

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