Skip to main content

pounce_nlp/
orig_ipopt_nlp.rs

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