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