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