Skip to main content

pounce_nlp/
ipopt_nlp.rs

1//! NLP traits consumed by the algorithm core — port of `IpNLP.hpp` /
2//! `IpIpoptNLP.hpp`.
3//!
4//! These traits live in `pounce-nlp` (rather than `pounce-algorithm`)
5//! so that the concrete [`crate::orig_ipopt_nlp::OrigIpoptNlp`], which
6//! wraps a `TNLPAdapter` from this same crate, can implement them
7//! without forcing `pounce-nlp` to depend on `pounce-algorithm` (the
8//! reverse dependency already exists). `pounce-algorithm` re-exports
9//! both traits from its own `ipopt_nlp` module so the rest of the
10//! algorithm-side code continues to use the canonical
11//! `crate::ipopt_nlp::IpoptNlp` path.
12
13use pounce_common::types::{Index, Number};
14use pounce_linalg::{DenseVector, Matrix, SymMatrix, SymTMatrix, SymTMatrixSpace, Vector};
15use std::rc::Rc;
16
17/// Human-readable names projected into the algorithm's *split* space —
18/// the index space the debugger reports residuals in, where equality and
19/// inequality constraints are separated and fixed variables are removed.
20///
21/// Each vector is indexed by the split-space position (`x_var[j]` is the
22/// `j`-th free variable, `eq[k]` the `k`-th equality constraint, `ineq[k]`
23/// the `k`-th inequality), and each entry is `Some(name)` when the model
24/// carried one or `None` to fall back to an index label. Producing this
25/// requires composing the TNLP's original-order names with the
26/// fixed-variable and c/d-split permutations, which is why it lives on
27/// the NLP rather than being read directly off the TNLP.
28///
29/// Names are what turn "variables 1, 132, 439 in equations 3, 15" into a
30/// model-level diagnosis — the gap Lee et al. (2024,
31/// <https://doi.org/10.69997/sct.147875>) call out for equation-oriented
32/// model debugging.
33#[derive(Debug, Clone, Default)]
34pub struct SplitNames {
35    /// Names of the free variables, in algorithm-side `x` order (`n()`).
36    pub x_var: Vec<Option<String>>,
37    /// Names of the equality constraints, in `c` order (`m_eq()`).
38    pub eq: Vec<Option<String>>,
39    /// Names of the inequality constraints, in `d` order (`m_ineq()`).
40    pub ineq: Vec<Option<String>>,
41}
42
43impl SplitNames {
44    /// Whether any entry carries a name. An all-`None` projection (e.g.
45    /// the model shipped no `.col`/`.row` files, or presolve declined to
46    /// forward names) is reported as "no names available" so the debugger
47    /// falls back to index labels rather than printing blanks.
48    pub fn any_present(&self) -> bool {
49        self.x_var
50            .iter()
51            .chain(self.eq.iter())
52            .chain(self.ineq.iter())
53            .any(Option::is_some)
54    }
55}
56
57/// Lower-level NLP interface (post-`TNLPAdapter`). Equality and
58/// inequality constraints are already separated; bounds are already
59/// classified into `x_l_map` / `x_u_map` / etc.
60///
61/// This is the equivalent of upstream `Ipopt::NLP`.
62pub trait Nlp {
63    fn n(&self) -> Index;
64    fn m_eq(&self) -> Index;
65    fn m_ineq(&self) -> Index;
66
67    fn eval_f(&mut self, x: &dyn Vector) -> Number;
68    fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector);
69    fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector);
70    fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector);
71    fn eval_jac_c(&mut self, x: &dyn Vector) -> Rc<dyn Matrix>;
72    fn eval_jac_d(&mut self, x: &dyn Vector) -> Rc<dyn Matrix>;
73    fn eval_h(
74        &mut self,
75        x: &dyn Vector,
76        obj_factor: Number,
77        y_c: &dyn Vector,
78        y_d: &dyn Vector,
79    ) -> Rc<dyn SymMatrix>;
80}
81
82/// Algorithm-side NLP (adds scaling-aware variants and provides the
83/// bound expansion matrices `Px_L`, `Px_U`, `Pd_L`, `Pd_U`). Mirrors
84/// upstream `Ipopt::IpoptNLP`.
85/// A `SymTMatrix` over `space` with all values explicitly set to zero.
86///
87/// `SymTMatrix::new` leaves a non-empty matrix flagged uninitialized, and
88/// `values()` asserts on that — so a zero-W block built for its sparsity
89/// alone has to be zeroed before anything walks it.
90pub fn zeroed_sym_t(space: Rc<SymTMatrixSpace>) -> SymTMatrix {
91    let nz = space.nonzeros() as usize;
92    let mut m = SymTMatrix::new(space);
93    m.set_values(&vec![0.0; nz]);
94    m
95}
96
97pub trait IpoptNlp: Nlp {
98    /// Drop every cached constraint value (`c`, `d`) so the next read
99    /// evaluates again, keeping the cached derivatives. For a model whose
100    /// constraint values moved at a fixed point while its derivatives did
101    /// not ([`crate::tnlp::TNLP::update_with_barrier`]: the coupled
102    /// continuation shifts its rows by a constant). Returns `false` when the
103    /// implementor cannot, and the caller must invalidate by re-issuing the
104    /// point under a fresh tag instead.
105    fn invalidate_constraint_values(&self) -> bool {
106        false
107    }
108
109    /// Per-evaluation call counts accumulated over the solve, ordered
110    /// `[f, grad_f, c, d, jac_c, jac_d, h]`. Populates the end-of-run
111    /// summary's evaluation tallies (#206). Default is all zeros for
112    /// implementors that do not count; [`OrigIpoptNlp`] reports its live
113    /// counters.
114    fn eval_counts(&self) -> [Index; 7] {
115        [0; 7]
116    }
117
118    /// A zero-valued `SymMatrix` carrying the Lagrangian Hessian's
119    /// *sparsity* and nothing else — upstream's `IpNLP::uninitialized_h`
120    /// (`IpIpoptNLP.hpp`), which `IpLeastSquareMults.cpp:38` uses to
121    /// build its `zeroW` block.
122    ///
123    /// The multiplier least-squares system and the default initializer
124    /// need a W block only so `StdAugSystemSolver` pins its triplet
125    /// structure with the W slots present; they pass `w_factor = 0.0`, so
126    /// the values are never read. Reaching for `curr_exact_hessian()`
127    /// there — an unmemoized `eval_h` — asks the user for a Hessian they
128    /// may have declared they cannot supply, which is exactly the case
129    /// under `hessian_approximation = limited-memory` (gh#698).
130    ///
131    /// Unlike upstream, the values are explicitly **zeroed** rather than
132    /// left uninitialized. Upstream can hand over uninitialized storage
133    /// because `w_factor = 0.0` means nothing reads it; pounce's
134    /// `StdAugSystemSolver::refill_values` still walks the W slots to
135    /// scale them, so the matrix has to be readable.
136    ///
137    /// The default implementation returns an empty (zero-nonzero) block
138    /// of the right dimension, which is correct for any NLP whose W is
139    /// structurally empty and safe for the rest: a caller that passes
140    /// `w_factor = 0.0` only ever needed the slots.
141    fn uninitialized_h(&self) -> Rc<dyn SymMatrix> {
142        Rc::new(zeroed_sym_t(SymTMatrixSpace::new(
143            self.x_l().dim(),
144            Vec::new(),
145            Vec::new(),
146        )))
147    }
148
149    fn x_l(&self) -> &dyn Vector;
150    fn x_u(&self) -> &dyn Vector;
151    fn d_l(&self) -> &dyn Vector;
152    fn d_u(&self) -> &dyn Vector;
153
154    /// The *declared* compressed inequality bounds `(d_L, d_U)`, in the same
155    /// (internally scaled) space as [`Self::d_l`] / [`Self::d_u`] but without
156    /// the `bound_relax_factor` widening or safe-slack adjustments the live
157    /// vectors carry. The scale-relative feasibility measure keys row
158    /// magnitudes off these: on the live vector a relaxed zero bound reads as
159    /// `~1e-8`, fabricating a magnitude for a row that has none. `None` (the
160    /// default) means "not tracked" — callers should fall back to the live
161    /// bounds.
162    fn declared_d_bounds(&self) -> Option<(Vec<Number>, Vec<Number>)> {
163        None
164    }
165
166    /// The *declared* compressed variable bounds `(x_L, x_U)` — the box the
167    /// user wrote, before `bound_relax_factor` widened it, in the same
168    /// compressed spaces as [`Self::x_l`] / [`Self::x_u`].
169    ///
170    /// Same "declared, not live" contract as [`Self::declared_d_bounds`].
171    /// Anything that reports *where the solution sits relative to the model*
172    /// — active-set identification above all — has to ask this rather than
173    /// the live vector: an iterate exactly on a declared bound is `1e-8`
174    /// inside the relaxed one, so a tolerance test against the live bounds
175    /// calls it inactive. `None` (the default) means "not tracked" — callers
176    /// should fall back to the live bounds.
177    fn declared_x_bounds(&self) -> Option<(Vec<Number>, Vec<Number>)> {
178        None
179    }
180
181    /// How far `x` sits outside the **declared** variable box — the box the
182    /// user wrote, before `bound_relax_factor` widened it.
183    ///
184    /// [`Self::declared_x_bounds`] returns those bounds in the *compressed*
185    /// spaces, which a caller holding an algorithm-space iterate cannot line
186    /// up on its own: the map from a bound slot to a full-x index runs through
187    /// the fixed-variable classification, and the iterate may be a compound
188    /// vector with no flat values to index. This does the lift and the
189    /// comparison where both are known.
190    ///
191    /// `None` means "not tracked" — no widening was recorded, so the declared
192    /// box and the live one are the same and there is nothing to add.
193    fn declared_box_violation(&self, _x: &dyn Vector) -> Option<Number> {
194        None
195    }
196
197    /// The *declared* equality right-hand sides `b` — the pre-fold constants
198    /// subtracted to turn `g_i(x) == b_i` into the algorithm's residual
199    /// `c_i(x) = 0` — reported in the same (internally scaled) space as
200    /// [`Nlp::eval_c`]'s output, so `|c_i| / |b_i|` is a pure ratio.
201    ///
202    /// The fold is exactly what erases the row's magnitude: `|c_i|` *is* the
203    /// violation and carries no independent scale, so a scale-relative
204    /// feasibility measure has nothing to divide by unless the RHS is plumbed
205    /// back. Same "declared, not live" contract as [`Self::declared_d_bounds`]:
206    /// the value is the one the user wrote (times any row scaling the solver
207    /// itself applied), never a relaxed or otherwise adjusted stand-in.
208    ///
209    /// `None` (the default) means "not tracked" — callers must then abstain
210    /// from any relative verdict on the `c` block rather than substitute a
211    /// magnitude of their own.
212    fn declared_c_rhs(&self) -> Option<Vec<Number>> {
213        None
214    }
215
216    /// Bound expansion matrices: `Px_L` extracts the
217    /// `x` components that have a finite lower bound, etc.
218    fn px_l(&self) -> Rc<dyn Matrix>;
219    fn px_u(&self) -> Rc<dyn Matrix>;
220    fn pd_l(&self) -> Rc<dyn Matrix>;
221    fn pd_u(&self) -> Rc<dyn Matrix>;
222
223    /// Replace the `x_L / x_U / d_L / d_U` bounds in place. Invoked by the
224    /// algorithm's accept step when the safe-slack mechanism moved one or
225    /// more bounds (port of `IpoptNLP::AdjustVariableBounds`,
226    /// `IpOrigIpoptNLP.cpp:990-1001`). Default is a no-op for NLP
227    /// implementations that do not own mutable bound storage.
228    fn adjust_variable_bounds(
229        &mut self,
230        _new_x_l: &dyn Vector,
231        _new_x_u: &dyn Vector,
232        _new_d_l: &dyn Vector,
233        _new_d_u: &dyn Vector,
234    ) {
235    }
236
237    /// Fill `x` with the initial primal values (mirrors upstream
238    /// `IpoptNLP::GetStartingPoint`'s `init_x` flag). Default impl
239    /// leaves `x` at its current contents (typically the zero vector
240    /// produced by `make_new`).
241    fn get_starting_x(&mut self, _x: &mut dyn Vector) -> bool {
242        true
243    }
244
245    /// Prepare a complete primal-dual starting-point snapshot for a warm
246    /// start. The default is a no-op for NLP implementations that do not
247    /// route through a TNLP callback.
248    ///
249    /// The warm-start initializer calls this once before its separate
250    /// `get_starting_x` / `get_starting_y` / `get_starting_z` projections.
251    /// Implementations can therefore fetch all requested data in one callback,
252    /// matching Ipopt's single `GetStartingPoint` call.
253    fn prepare_warm_start(&mut self) -> bool {
254        true
255    }
256
257    /// Release any temporary state prepared for the warm-start projections.
258    ///
259    /// Called once the initializer has obtained its `x`, `y`, and `z` blocks.
260    /// The default is a no-op; adapters that cache a TNLP callback payload use
261    /// this to keep that snapshot scoped to one initialization only.
262    fn finish_warm_start(&mut self) {}
263
264    /// Fill `y_c` / `y_d` with initial multiplier guesses (mirrors
265    /// `IpoptNLP::GetStartingPoint`'s `init_lambda` flag). Default
266    /// impl leaves them at their current contents (zeros).
267    fn get_starting_y(&mut self, _y_c: &mut dyn Vector, _y_d: &mut dyn Vector) -> bool {
268        true
269    }
270
271    /// Fill `z_l` / `z_u` / `v_l` / `v_u` with initial bound-multiplier
272    /// guesses (mirrors `init_z`). Default impl leaves them at zeros.
273    #[allow(clippy::too_many_arguments)]
274    fn get_starting_z(
275        &mut self,
276        _z_l: &mut dyn Vector,
277        _z_u: &mut dyn Vector,
278        _v_l: &mut dyn Vector,
279        _v_u: &mut dyn Vector,
280    ) -> bool {
281        true
282    }
283
284    /// Lift a compressed `x_var` (length `n_x_var`) to the full-x
285    /// length (`n_full_x` = user TNLP's `n`), splicing fixed-variable
286    /// values back in. Used at finalize-solution time to hand the user
287    /// a full-length x. Default impl returns x as-is, valid when the
288    /// problem has no fixed variables.
289    fn lift_x_to_full(&self, x: &dyn Vector) -> Vec<Number> {
290        let dx = x
291            .as_any()
292            .downcast_ref::<DenseVector>()
293            .expect("IpoptNlp::lift_x_to_full expects DenseVector");
294        dx.expanded_values().to_vec()
295    }
296
297    /// The full-x to hand `TNLP::finalize_solution`: [`Self::lift_x_to_full`],
298    /// plus whatever the reported point owes the user that the working
299    /// iterate does not — today, the `honor_original_bounds` projection
300    /// back into the declared box (the `bound_relax_factor` widening
301    /// otherwise reports a bound-pinned solution just outside its own
302    /// bounds). Default impl is `lift_x_to_full`; `OrigIpoptNlp`
303    /// overrides.
304    fn finalize_solution_x(&self, x: &dyn Vector) -> Vec<Number> {
305        self.lift_x_to_full(x)
306    }
307
308    /// Pack the algorithm-side `(y_c, y_d)` constraint multipliers into
309    /// the user TNLP's `lambda` array (length `n_full_g`, ordered by
310    /// the original `g` index). Used by `GetIpoptCurrentIterate` and
311    /// `finalize_solution`. Default impl returns an empty vector — the
312    /// canonical `OrigIpoptNlp` implementation overrides it to perform
313    /// the c/d-split inverse and scaling unwind.
314    fn pack_lambda_for_user(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
315        Vec::new()
316    }
317
318    /// Pack the algorithm-side `(c, d)` constraint values into the user
319    /// TNLP's `g` array (length `n_full_g`, ordered by the original `g`
320    /// index, in user-unscaled space). Default impl returns an empty
321    /// vector; `OrigIpoptNlp` overrides.
322    fn pack_g_for_user(&self, _c: &dyn Vector, _d: &dyn Vector) -> Vec<Number> {
323        Vec::new()
324    }
325
326    /// Expand a compressed lower-bound-multiplier vector
327    /// (length = number of finite-lower-bound free variables) into the
328    /// user TNLP's full-`n` length `z_L` array. Default impl returns an
329    /// empty vector; `OrigIpoptNlp` overrides.
330    fn pack_z_l_for_user(&self, _z_l: &dyn Vector) -> Vec<Number> {
331        Vec::new()
332    }
333
334    /// Expand a compressed upper-bound-multiplier vector into the user
335    /// TNLP's full-`n` length `z_U` array. Default impl returns an
336    /// empty vector; `OrigIpoptNlp` overrides.
337    fn pack_z_u_for_user(&self, _z_u: &dyn Vector) -> Vec<Number> {
338        Vec::new()
339    }
340
341    /// Number of variables `n` as the user TNLP declared it (= `n_full_x`,
342    /// before fixed-variable elimination). Used by inspector entry
343    /// points that need to size full-`n` buffers. Default impl returns
344    /// 0; `OrigIpoptNlp` overrides.
345    fn n_full_x(&self) -> Index {
346        0
347    }
348
349    /// Number of constraints `m` as the user TNLP declared it (= `n_full_g`).
350    /// Default impl returns 0; `OrigIpoptNlp` overrides.
351    fn n_full_g(&self) -> Index {
352        0
353    }
354
355    /// Lift the algorithm-side `(y_c, y_d)` multipliers back to the
356    /// user TNLP's `lambda` array (length `m_full = n_c + n_d`),
357    /// matching upstream `IpOrigIpoptNLP::FinalizeSolution`. Sibling
358    /// to `pack_lambda_for_user`; added by pounce#11 for the
359    /// `finalize_solution` path. Default returns empty; `OrigIpoptNlp`
360    /// overrides.
361    fn finalize_solution_lambda(&self, _y_c: &dyn Vector, _y_d: &dyn Vector) -> Vec<Number> {
362        Vec::new()
363    }
364
365    /// Lift compressed `z_l` back to full-x. Sibling to
366    /// `pack_z_l_for_user`; added by pounce#11. Default returns empty.
367    fn finalize_solution_z_l(&self, _z_l: &dyn Vector) -> Vec<Number> {
368        Vec::new()
369    }
370
371    /// Lift compressed `z_u` back to full-x. Sibling to
372    /// `pack_z_u_for_user`; added by pounce#11. Default returns empty.
373    fn finalize_solution_z_u(&self, _z_u: &dyn Vector) -> Vec<Number> {
374        Vec::new()
375    }
376
377    /// Fill in, on the full-x `z_l` / `z_u` that
378    /// [`Self::finalize_solution_z_l`] / [`Self::finalize_solution_z_u`]
379    /// produced, the bound multipliers of the variables `make_parameter`
380    /// removed — which those lifts can only report as `0.0`, because the
381    /// algorithm never held one. `x` and `lambda` are the reported point
382    /// and constraint multipliers in the same space
383    /// (`finalize_solution_x` / `finalize_solution_lambda`). Default no-op:
384    /// an impl without fixed-variable elimination has nothing to fill.
385    fn complete_fixed_var_bound_multipliers(
386        &self,
387        _x: &[Number],
388        _lambda: &[Number],
389        _z_l: &mut [Number],
390        _z_u: &mut [Number],
391    ) {
392    }
393
394    /// Map a 0-based **full-x** index (user-TNLP space, length
395    /// `n_full_x()`) to a 0-based **var-x** index (algorithm-side,
396    /// length `n()`). Returns `None` when the variable was eliminated
397    /// because `x_l[i] == x_u[i]` under
398    /// `fixed_variable_treatment = make_parameter`.
399    ///
400    /// Default impl assumes no fixed variables (identity mapping). The
401    /// `OrigIpoptNlp` implementation consults
402    /// `BoundClassification::full_to_var`.
403    fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
404        Some(full_idx)
405    }
406
407    /// Map a 0-based **full-g** index (user-TNLP space, length
408    /// `n_full_g()`) to a 0-based position in the c-block (algorithm-side
409    /// equality multiplier vector `y_c`, length `m_eq()`). Returns
410    /// `None` when the constraint is an inequality (lives in `d`, not
411    /// `c`).
412    ///
413    /// Default impl assumes the c-block matches the user's g order
414    /// (no c/d split); `OrigIpoptNlp` overrides via
415    /// `BoundClassification::c_map`.
416    fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
417        Some(full_idx)
418    }
419
420    /// Map a 0-based **full-g** index to a 0-based position in the
421    /// d-block (algorithm-side inequality multiplier vector `y_d`,
422    /// length `m_ineq()`). Returns `None` when the constraint is an
423    /// equality (it lives in `c`, not `d`).
424    ///
425    /// The exact complement of [`Self::full_g_to_c_block`]: on
426    /// `OrigIpoptNlp` every row answers `Some` to exactly one of the
427    /// two. Added by gh#910 so a strictly active inequality's
428    /// multiplier sensitivity can be addressed at all — `y_d` was
429    /// already in the compound KKT vector, with no map to reach it.
430    ///
431    /// Default `None`, the complement of `full_g_to_c_block`'s
432    /// identity default: an impl that does not split c from d puts
433    /// every row in `c`, so it has no d-block to index.
434    fn full_g_to_d_block(&self, _full_idx: Index) -> Option<Index> {
435        None
436    }
437
438    /// Inverse of [`Self::full_x_to_var_x`]: map a 0-based var-x index
439    /// (length `n()`) to the corresponding full-x index (length
440    /// `n_full_x()`). Used when scattering a compressed step or
441    /// iterate back into the user's full-x array.
442    ///
443    /// Default impl assumes no fixed variables (identity); `OrigIpoptNlp`
444    /// returns `classification.x_not_fixed_map[var_idx]`.
445    fn var_x_to_full_x(&self, var_idx: Index) -> Index {
446        var_idx
447    }
448
449    /// Effective objective scaling factor (`df_` upstream): the value
450    /// `f` is multiplied by inside [`Self::eval_f`]. Used to recover the
451    /// unscaled objective for display. Default `1.0` (no scaling);
452    /// `OrigIpoptNlp` overrides.
453    fn obj_scaling_factor(&self) -> Number {
454        1.0
455    }
456
457    /// The **solver-computed** part of the objective scale, before the user's
458    /// constant `obj_scaling_factor` is multiplied in.
459    ///
460    /// [`Self::obj_scaling_factor`] returns the product `df * user_factor`,
461    /// which is the right thing for unscaling a residual but the wrong thing
462    /// for asking *why* the scale is small. `df` is what gradient-based scaling
463    /// computed and clamped at `nlp_scaling_min_value`; the user factor is a
464    /// deliberate choice. Only the former can mask a certificate (gh #200), so
465    /// the termination logic keys on this rather than on the product.
466    /// Default `1.0`; `OrigIpoptNlp` overrides.
467    fn computed_obj_scaling_factor(&self) -> Number {
468        1.0
469    }
470
471    /// gh#983: the largest objective-gradient entry at the starting point, as
472    /// gradient-based scaling measured it -- the model's own gradient scale,
473    /// independent of where the solve ends (an interior optimum has `grad f`
474    /// near zero there). `NaN` when no gradient-based scaling ran.
475    fn start_obj_gradient_max(&self) -> Number {
476        Number::NAN
477    }
478
479    /// Per-row scaling vector for the equality block (`dc_` upstream):
480    /// the factor each `c` row is multiplied by inside [`Self::eval_c`]
481    /// / [`Self::eval_jac_c`]. `None` ⇔ no row scaling (all 1.0);
482    /// length `m_eq()` when present. Together with
483    /// [`Self::obj_scaling_factor`] and [`Self::d_scale_vec`] this is
484    /// what lets `pounce-sensitivity` undo the NLP scaling baked into
485    /// the converged KKT factor (pounce#128). Default `None`;
486    /// `OrigIpoptNlp` overrides.
487    fn c_scale_vec(&self) -> Option<Vec<Number>> {
488        None
489    }
490
491    /// Per-row scaling vector for the inequality block (`dd_`
492    /// upstream), same convention as [`Self::c_scale_vec`]. Length
493    /// `m_ineq()` when present. Default `None`; `OrigIpoptNlp`
494    /// overrides.
495    fn d_scale_vec(&self) -> Option<Vec<Number>> {
496        None
497    }
498
499    /// The per-variable factors `d` a scaling wrapper below this NLP
500    /// applied as a change of variables `x̃ = d ⊙ x` (gh#486). `None`
501    /// ⇔ no variable scaling; length [`Self::n_full_x`] when present,
502    /// i.e. the **full-x** space of the TNLP that was submitted, before
503    /// fixed variables were dropped.
504    ///
505    /// This is the x-axis counterpart of [`Self::obj_scaling_factor`] /
506    /// [`Self::c_scale_vec`] / [`Self::d_scale_vec`], and it exists for
507    /// the same reason: a consumer reading the converged KKT system
508    /// rather than the `finalize_solution` payload is looking at `x̃`,
509    /// not `x`, and needs the factors to say so. Unlike the other
510    /// three, the substitution happens *below* the NLP — in
511    /// `ScalingTnlp` — so this only forwards what the TNLP reports.
512    /// Default `None`; `OrigIpoptNlp` overrides.
513    fn variable_scaling(&self) -> Option<Vec<Number>> {
514        None
515    }
516
517    /// Human-readable variable / constraint names projected into the
518    /// algorithm's split space (free variables, equalities, inequalities),
519    /// or `None` when the model carries no names. The debugger uses this to
520    /// label residuals by model name (`mass_balance`) rather than index
521    /// (`c[3]`) — see [`SplitNames`] and Lee et al. (2024,
522    /// <https://doi.org/10.69997/sct.147875>).
523    ///
524    /// Default returns `None`; `OrigIpoptNlp` overrides by pulling
525    /// `idx_names` metadata from the underlying TNLP and composing it with
526    /// the bound / c-d-split permutations.
527    fn split_space_names(&self) -> Option<SplitNames> {
528        None
529    }
530}