Skip to main content

pounce_nlp/
orig_ipopt_nlp.rs

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