Skip to main content

pounce_algorithm/init/
default.rs

1//! Default iterate initializer — port of
2//! `Algorithm/IpDefaultIterateInitializer.{hpp,cpp}`.
3//!
4//! Bound push, slack init, multiplier init (constant / mu-based /
5//! least-square via the `EqMultCalculator`). Constants below match
6//! upstream's defaults from `RegisterOptions`.
7//!
8//! `set_initial_iterates` ports the upstream sequence:
9//!
10//! 1. Pull `x` from `nlp.get_starting_x` and push each component
11//!    into the interior of `[x_l, x_u]` per
12//!    [`DefaultIterateInitializer::push_to_interior`].
13//! 2. Set `s = d(x)` (evaluated through CQ on a transient iterate)
14//!    and push it into the interior of `[d_l, d_u]`.
15//! 3. Initialize `y_c`, `y_d`:
16//!    * `bound_mult_init_method == "constant"` — leave at zero (the
17//!      default y-targets that the linear-solver sweep will refine).
18//!    * `bound_mult_init_method == "least-square"` — call
19//!      [`crate::eq_mult::least_square::LeastSquareMults`] via the
20//!      provided `aug_solver`. **Phase 7 default is "constant"** to
21//!      avoid pulling the aug-system through this path on bring-up.
22//! 4. Initialize `z_l`, `z_u`, `v_l`, `v_u` to `bound_mult_init_val`
23//!    (component-wise).
24//!
25//! The fully-loaded mu-based / least-square multiplier paths land
26//! once `LeastSquareMults` and the iterate-trace gold tests are
27//! online.
28
29use crate::eq_mult::r#trait::EqMultCalculator;
30use crate::init::r#trait::IterateInitializer;
31use crate::ipopt_cq::IpoptCqHandle;
32use crate::ipopt_data::IpoptDataHandle;
33use crate::ipopt_nlp::IpoptNlp;
34use crate::iterates_vector::IteratesVector;
35use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
36use pounce_common::types::{Index, Number};
37use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
38use pounce_linalg::Vector;
39use std::cell::RefCell;
40use std::rc::Rc;
41
42pub struct DefaultIterateInitializer {
43    pub bound_push: Number,
44    pub bound_frac: Number,
45    pub slack_bound_push: Number,
46    pub slack_bound_frac: Number,
47    pub constr_mult_init_max: Number,
48    pub bound_mult_init_val: Number,
49    /// "constant" / "mu-based" / "least-square".
50    pub bound_mult_init_method: String,
51    /// Equality-multiplier calculator used by the
52    /// `least_square_mults` step at the end of `set_initial_iterates`,
53    /// matching upstream `IpDefaultIterateInitializer.cpp:334-341`. If
54    /// `None`, the LS step is skipped (y_c, y_d remain at zero).
55    pub eq_mult_calculator: Option<Box<dyn EqMultCalculator>>,
56    /// `least_square_init_primal` — port of
57    /// `IpDefaultIterateInitializer.cpp:200-222`. When on, the
58    /// initializer replaces the user's starting `x` with the min-norm
59    /// solution of the linearized equality + inequality constraints,
60    /// then pushes that to the interior. Used by the Mehrotra cascade
61    /// (`IpIpoptAlg.cpp:182`) to dramatically reduce iter-0 primal
62    /// infeasibility on LP-shaped problems.
63    pub least_square_init_primal: bool,
64}
65
66impl Default for DefaultIterateInitializer {
67    fn default() -> Self {
68        Self {
69            bound_push: 1e-2,
70            bound_frac: 1e-2,
71            slack_bound_push: 1e-2,
72            slack_bound_frac: 1e-2,
73            constr_mult_init_max: 1e3,
74            bound_mult_init_val: 1.0,
75            bound_mult_init_method: "constant".into(),
76            eq_mult_calculator: None,
77            least_square_init_primal: false,
78        }
79    }
80}
81
82impl DefaultIterateInitializer {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    pub fn with_eq_mult_calculator(eq_mult: Box<dyn EqMultCalculator>) -> Self {
88        Self {
89            eq_mult_calculator: Some(eq_mult),
90            ..Self::default()
91        }
92    }
93
94    /// Per-element bound-push formula from upstream
95    /// `IpDefaultIterateInitializer.cpp:473-666`. Given a primal value
96    /// `x` and optional bounds `(lower, upper)`, return a value
97    /// shifted to the interior:
98    ///
99    /// * Two-sided bounds: clamp into `[lo + p_l, hi - p_u]` where
100    ///   `p_l = min(bound_push * max(|lo|, 1), bound_frac * (hi-lo))`,
101    ///   `p_u = min(bound_push * max(|hi|, 1), bound_frac * (hi-lo))`.
102    /// * Lower-only: return `max(x, lo + bound_push * max(|lo|, 1))`.
103    /// * Upper-only: return `min(x, hi - bound_push * max(|hi|, 1))`.
104    /// * Free: return `x`.
105    ///
106    /// The `Px_L`/`Px_U` selection-matrix dance in upstream collapses
107    /// to exactly this per-coordinate formula once the bounds are
108    /// expanded to the full primal space.
109    /// Port of `IpDefaultIterateInitializer.cpp:CalculateLeastSquarePrimals`.
110    /// Solves the augmented system with `W=0`, `D_x=I`, `D_s=I` and
111    /// `rhs=(0, 0, curr_c, curr_d)`; on success returns the min-norm
112    /// `x_ls` (negated per upstream `x_ls.Scal(-1)`). `s_ls` is
113    /// discarded — upstream overwrites it with `trial_d(trial_x)`
114    /// after pushing `x_ls` to the interior, so we save the allocation
115    /// and re-evaluate `d` later in `set_initial_iterates`. Assumes
116    /// `data.curr.x` already holds the point at which the constraints
117    /// and Jacobians should be linearized.
118    fn calculate_least_square_primals(
119        &self,
120        cq: &IpoptCqHandle,
121        _nlp: &Rc<RefCell<dyn IpoptNlp>>,
122        aug_solver: &mut dyn AugSystemSolver,
123        n_x: Index,
124    ) -> Option<Rc<dyn Vector>> {
125        let cq_ref = cq.borrow();
126        let curr_c = cq_ref.curr_c();
127        let curr_d = cq_ref.curr_d();
128        let j_c = cq_ref.curr_jac_c();
129        let j_d = cq_ref.curr_jac_d();
130        // `zeroW` pins the W triplet structure in the linsol so later
131        // calls with the real Hessian write into the right slots
132        // (mirrors `IpLeastSquareMults`).
133        let zero_w = cq_ref.curr_exact_hessian();
134        drop(cq_ref);
135
136        let n_s = curr_d.dim();
137        let n_c = curr_c.dim();
138        let n_d = curr_d.dim();
139
140        let mut rhs_x = DenseVectorSpace::new(n_x).make_new_dense();
141        rhs_x.set(0.0);
142        let mut rhs_s = DenseVectorSpace::new(n_s).make_new_dense();
143        rhs_s.set(0.0);
144        let mut rhs_c_v = curr_c.make_new();
145        rhs_c_v.copy(&*curr_c);
146        let mut rhs_d_v = curr_d.make_new();
147        rhs_d_v.copy(&*curr_d);
148
149        let mut sol_x = DenseVectorSpace::new(n_x).make_new_dense();
150        let mut sol_s = DenseVectorSpace::new(n_s).make_new_dense();
151        let mut sol_c = DenseVectorSpace::new(n_c).make_new_dense();
152        let mut sol_d = DenseVectorSpace::new(n_d).make_new_dense();
153
154        let coeffs = AugSysCoeffs {
155            w: Some(&*zero_w),
156            w_factor: 0.0,
157            d_x: None,
158            delta_x: 1.0,
159            d_s: None,
160            delta_s: 1.0,
161            j_c: &*j_c,
162            d_c: None,
163            // Tiny δ_c, δ_d (upstream uses 0). pounce-feral's LDL^T
164            // mis-reports the inertia of an augmented system with a
165            // structurally-zero (3,3)/(4,4) block — it counted 0
166            // negative eigenvalues on nuffield2_trap where the true
167            // count is n_c+n_d, triggering WrongInertia. Perturbing
168            // by 1e-8 keeps the LS solution numerically identical
169            // (the constraint Jacobian dominates this term) while
170            // giving the diagonal something nonzero to pivot on.
171            delta_c: 1e-8,
172            j_d: &*j_d,
173            d_d: None,
174            delta_d: 1e-8,
175        };
176        let aug_rhs = AugSysRhs {
177            rhs_x: &rhs_x,
178            rhs_s: &rhs_s,
179            rhs_c: &*rhs_c_v,
180            rhs_d: &*rhs_d_v,
181        };
182        let mut sol = AugSysSol {
183            sol_x: &mut sol_x,
184            sol_s: &mut sol_s,
185            sol_c: &mut sol_c,
186            sol_d: &mut sol_d,
187        };
188
189        // Upstream `IpDefaultIterateInitializer.cpp:381` passes
190        // check_NegEVals=true, numberOfNegEVals=n_c+n_d (matches the
191        // expected inertia of the W=0,Dx=I,Ds=I augmented system).
192        let num_eq = n_c + n_d;
193        let check_neg = aug_solver.provides_inertia();
194        let status = aug_solver.solve(&coeffs, &aug_rhs, &mut sol, check_neg, num_eq);
195        if !matches!(status, pounce_linsol::ESymSolverStatus::Success) {
196            return None;
197        }
198        // Upstream `IpDefaultIterateInitializer.cpp:386-387`:
199        // x_ls.Scal(-1); s_ls.Scal(-1).
200        sol_x.scal(-1.0);
201        Some(Rc::new(sol_x))
202    }
203
204    pub fn push_to_interior(
205        bound_push: Number,
206        bound_frac: Number,
207        x: Number,
208        lower: Option<Number>,
209        upper: Option<Number>,
210    ) -> Number {
211        match (lower, upper) {
212            (Some(lo), Some(hi)) => {
213                let span = hi - lo;
214                let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
215                let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
216                x.max(lo + p_l).min(hi - p_u)
217            }
218            (Some(lo), None) => {
219                let p_l = bound_push * lo.abs().max(1.0);
220                x.max(lo + p_l)
221            }
222            (None, Some(hi)) => {
223                let p_u = bound_push * hi.abs().max(1.0);
224                x.min(hi - p_u)
225            }
226            (None, None) => x,
227        }
228    }
229}
230
231impl IterateInitializer for DefaultIterateInitializer {
232    fn set_initial_iterates(
233        &mut self,
234        data: &IpoptDataHandle,
235        cq: &IpoptCqHandle,
236        nlp: &Rc<RefCell<dyn IpoptNlp>>,
237        aug_solver: &mut dyn AugSystemSolver,
238    ) -> bool {
239        let curr_template = match data.borrow().curr.clone() {
240            Some(c) => c,
241            None => return false,
242        };
243
244        let n_x = curr_template.x.dim();
245        let n_s = curr_template.s.dim();
246        let n_yc = curr_template.y_c.dim();
247        let n_yd = curr_template.y_d.dim();
248        let n_zl = curr_template.z_l.dim();
249        let n_zu = curr_template.z_u.dim();
250        let n_vl = curr_template.v_l.dim();
251        let n_vu = curr_template.v_u.dim();
252
253        // Step 1: pull x from NLP and push each finite-bounded
254        // component into the interior. Bound vectors `x_l`, `x_u` are
255        // packed (only finite entries); we expand via `Px_L^T` masks
256        // by walking the dense slot.
257        let mut x = DenseVectorSpace::new(n_x).make_new_dense();
258        nlp.borrow_mut().get_starting_x(&mut x);
259
260        // Step 1.5 (optional): replace `x` with the min-norm solution
261        // of the linearized equality + inequality constraints. Port of
262        // `IpDefaultIterateInitializer.cpp:200-222`. The Mehrotra
263        // cascade in `application.rs` turns this on; it is the iter-0
264        // feasibility correction that lets Mehrotra LPs land on the
265        // central path on the first solve. Failure leaves `x` as-is.
266        if self.least_square_init_primal && (n_yc + n_yd) > 0 {
267            // Stage a partial iterate with the user's starting `x` and
268            // zeros for everything else, so `cq.curr_*` evaluates at
269            // the right point.
270            let mut x_stage = DenseVectorSpace::new(n_x).make_new_dense();
271            x_stage.copy(&x);
272            let mut s_zero = DenseVectorSpace::new(n_s).make_new_dense();
273            s_zero.set(0.0);
274            let mut y_c_zero = DenseVectorSpace::new(n_yc).make_new_dense();
275            y_c_zero.set(0.0);
276            let mut y_d_zero = DenseVectorSpace::new(n_yd).make_new_dense();
277            y_d_zero.set(0.0);
278            let mut z_l_zero = DenseVectorSpace::new(n_zl).make_new_dense();
279            z_l_zero.set(0.0);
280            let mut z_u_zero = DenseVectorSpace::new(n_zu).make_new_dense();
281            z_u_zero.set(0.0);
282            let mut v_l_zero = DenseVectorSpace::new(n_vl).make_new_dense();
283            v_l_zero.set(0.0);
284            let mut v_u_zero = DenseVectorSpace::new(n_vu).make_new_dense();
285            v_u_zero.set(0.0);
286            let stage_iv = IteratesVector::new(
287                Rc::new(x_stage),
288                Rc::new(s_zero),
289                Rc::new(y_c_zero),
290                Rc::new(y_d_zero),
291                Rc::new(z_l_zero),
292                Rc::new(z_u_zero),
293                Rc::new(v_l_zero),
294                Rc::new(v_u_zero),
295            );
296            data.borrow_mut().set_curr(stage_iv);
297
298            if let Some(x_ls) = self.calculate_least_square_primals(cq, nlp, aug_solver, n_x) {
299                x.copy(&*x_ls);
300            }
301        }
302
303        {
304            let nlp_ref = nlp.borrow();
305            push_x_into_interior(
306                &mut x,
307                &*nlp_ref.px_l(),
308                nlp_ref.x_l(),
309                &*nlp_ref.px_u(),
310                nlp_ref.x_u(),
311                self.bound_push,
312                self.bound_frac,
313            );
314        }
315
316        // Step 2: s = d(x), then push into [d_l, d_u].
317        let mut s = DenseVectorSpace::new(n_s).make_new_dense();
318        nlp.borrow_mut().eval_d(&x, &mut s);
319        {
320            let nlp_ref = nlp.borrow();
321            push_x_into_interior(
322                &mut s,
323                &*nlp_ref.pd_l(),
324                nlp_ref.d_l(),
325                &*nlp_ref.pd_u(),
326                nlp_ref.d_u(),
327                self.slack_bound_push,
328                self.slack_bound_frac,
329            );
330        }
331
332        // Step 3: y_c, y_d initial guesses. `constant` mode leaves
333        // them at zero (the algorithm refines on the first KKT solve).
334        let mut y_c = DenseVectorSpace::new(n_yc).make_new_dense();
335        let mut y_d = DenseVectorSpace::new(n_yd).make_new_dense();
336        if self.bound_mult_init_method == "constant" {
337            // Materialize as homogeneous-zero so callers' asum / values
338            // probes don't trip the `initialized` debug-assert.
339            y_c.set(0.0);
340            y_d.set(0.0);
341        } else {
342            // Other modes (mu-based, least-square) require the
343            // aug-system path; fall back to NLP's own y-init for now.
344            nlp.borrow_mut().get_starting_y(&mut y_c, &mut y_d);
345            cap_constraint_multipliers(&mut y_c, self.constr_mult_init_max);
346            cap_constraint_multipliers(&mut y_d, self.constr_mult_init_max);
347        }
348
349        // Step 4: bound multipliers — constant init.
350        let mut z_l = DenseVectorSpace::new(n_zl).make_new_dense();
351        let mut z_u = DenseVectorSpace::new(n_zu).make_new_dense();
352        let mut v_l = DenseVectorSpace::new(n_vl).make_new_dense();
353        let mut v_u = DenseVectorSpace::new(n_vu).make_new_dense();
354        z_l.set(self.bound_mult_init_val);
355        z_u.set(self.bound_mult_init_val);
356        v_l.set(self.bound_mult_init_val);
357        v_u.set(self.bound_mult_init_val);
358
359        let iv = IteratesVector::new(
360            Rc::new(x),
361            Rc::new(s),
362            Rc::new(y_c),
363            Rc::new(y_d),
364            Rc::new(z_l),
365            Rc::new(z_u),
366            Rc::new(v_l),
367            Rc::new(v_u),
368        );
369        let n_x_dim = iv.x.dim();
370        data.borrow_mut().set_curr(iv);
371
372        // Step 5: least-square equality multipliers — port of
373        // `IpDefaultIterateInitializer.cpp:285-341` /
374        // `least_square_mults` (lines 669-743). Upstream always runs
375        // this after the constant-init y_c/y_d=0, unless the full
376        // `least_square_init_duals` path succeeded. Without it the
377        // initial gradient-of-Lagrangian residual is computed against
378        // y_c=y_d=0, blowing up `inf_du` on iter 0.
379        if n_yc != n_x_dim
380            && self.constr_mult_init_max > 0.0
381            && (n_yc + n_yd) > 0
382            && self.eq_mult_calculator.is_some()
383        {
384            let mut new_y_c = DenseVectorSpace::new(n_yc).make_new_dense();
385            let mut new_y_d = DenseVectorSpace::new(n_yd).make_new_dense();
386            let calc = self.eq_mult_calculator.as_mut().unwrap();
387            let ok = calc.calculate_y_eq(data, cq, nlp, aug_solver, &mut new_y_c, &mut new_y_d);
388            if !ok {
389                // Solver failed → leave at zero (already the case).
390                data.borrow_mut().append_info_string("y0");
391            } else {
392                let yinitnrm = new_y_c.amax().max(new_y_d.amax());
393                if yinitnrm > self.constr_mult_init_max {
394                    // Cap exceeded → upstream zeros them out
395                    // (`IpDefaultIterateInitializer.cpp:723-727`).
396                    data.borrow_mut().append_info_string("yc");
397                } else {
398                    // Accept LS estimates. Build a fresh iterate
399                    // sharing the existing x/s/z/v Rcs and replacing
400                    // y_c, y_d with the LS values.
401                    let curr = data.borrow().curr.clone();
402                    if let Some(c) = curr {
403                        let new_iv = IteratesVector::new(
404                            c.x.clone(),
405                            c.s.clone(),
406                            Rc::new(new_y_c),
407                            Rc::new(new_y_d),
408                            c.z_l.clone(),
409                            c.z_u.clone(),
410                            c.v_l.clone(),
411                            c.v_u.clone(),
412                        );
413                        let mut d = data.borrow_mut();
414                        d.set_curr(new_iv);
415                        d.append_info_string("y");
416                    }
417                }
418            }
419        }
420
421        true
422    }
423}
424
425/// Apply [`DefaultIterateInitializer::push_to_interior`] to every
426/// component of `x` using the lower/upper bound vectors expanded
427/// through the `P_L`/`P_U` selection matrices. Bounds are packed
428/// (lower-bound vector `x_l` has dim equal to the number of
429/// lower-bounded components; `Px_L: n × n_lo` selects them).
430pub(crate) fn push_x_into_interior(
431    x: &mut DenseVector,
432    px_l: &dyn pounce_linalg::Matrix,
433    x_l: &dyn Vector,
434    px_u: &dyn pounce_linalg::Matrix,
435    x_u: &dyn Vector,
436    bound_push: Number,
437    bound_frac: Number,
438) {
439    // Use `dim()` (not `values().len()`): the iterate initializer is
440    // called before any user `x0` has been written, so `x` is still in
441    // its default homogeneous-zero state. `values()` carries a
442    // `debug_assert!(!self.homogeneous)` and trips in debug builds on
443    // clnlbeam.nl-class problems (n=59999, x_L/x_U packed). `values_mut()`
444    // below materializes the dense buffer before the per-element write.
445    let n = x.dim() as usize;
446    // Expand x_l and x_u into full-length sentinel vectors:
447    //   lower[i] = Some(x_l_packed[k]) if i is the k-th lower-bounded slot
448    //   upper[i] = Some(x_u_packed[k]) similarly.
449    let mut lower = vec![None; n];
450    let mut upper = vec![None; n];
451    expand_packed_into_dense(px_l, x_l, &mut lower);
452    expand_packed_into_dense(px_u, x_u, &mut upper);
453
454    let xs = x.values_mut();
455    for (i, xi) in xs.iter_mut().enumerate() {
456        *xi = DefaultIterateInitializer::push_to_interior(
457            bound_push, bound_frac, *xi, lower[i], upper[i],
458        );
459    }
460}
461
462/// Apply `P` to a packed bound vector `b_packed` (dim `n_pack`) to
463/// produce a sparse marking of `out` (dim `P.n_rows`). For each
464/// `k = 0..n_pack`, `out[P_rows[k]] = Some(b_packed[k])`. Falls back
465/// to a column-by-column probe via `mult_vector` if downcast to
466/// `ExpansionMatrix` is unavailable.
467fn expand_packed_into_dense(
468    p: &dyn pounce_linalg::Matrix,
469    b_packed: &dyn Vector,
470    out: &mut [Option<Number>],
471) {
472    use pounce_linalg::expansion_matrix::ExpansionMatrix;
473    let dim_packed = b_packed.dim() as usize;
474    if dim_packed == 0 {
475        return;
476    }
477
478    if let Some(em) = p.as_any().downcast_ref::<ExpansionMatrix>() {
479        let rows = em.expanded_pos_indices();
480        let Some(packed) = b_packed.as_any().downcast_ref::<DenseVector>() else {
481            unreachable!("expansion-matrix bound vec must be DenseVector")
482        };
483        let vals = packed.values();
484        for k in 0..dim_packed {
485            let row = rows[k] as usize;
486            out[row] = Some(vals[k]);
487        }
488    } else {
489        // Generic fallback: probe via mult_vector with unit input
490        // vectors. Quadratic; fine for tiny problems and tests.
491        let n_full = out.len() as i32;
492        let mut tmp = DenseVectorSpace::new(n_full).make_new_dense();
493        for k in 0..dim_packed {
494            let mut e_k = DenseVectorSpace::new(b_packed.dim()).make_new_dense();
495            e_k.values_mut()[k] = 1.0;
496            tmp.set(0.0);
497            p.mult_vector(1.0, &e_k, 0.0, &mut tmp);
498            // tmp is the k-th expansion column: a single 1.0 at the
499            // expanded position. Read the value we want into the
500            // matching slot.
501            let Some(packed) = b_packed.as_any().downcast_ref::<DenseVector>() else {
502                unreachable!("packed bound vec must be DenseVector")
503            };
504            for (i, &t) in tmp.values().iter().enumerate() {
505                if t == 1.0 {
506                    out[i] = Some(packed.values()[k]);
507                }
508            }
509        }
510    }
511}
512
513/// Clamp every component of `y` to `[-max, max]`. Mirrors the
514/// upstream `constr_mult_init_max` cap.
515fn cap_constraint_multipliers(y: &mut DenseVector, max: Number) {
516    for v in y.values_mut() {
517        if *v > max {
518            *v = max;
519        } else if *v < -max {
520            *v = -max;
521        }
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn interior_point_left_alone() {
531        // x=5 strictly inside [0, 10] with bound_push=1e-2 →
532        // p_l = min(1e-2 * max(0,1), 1e-2 * 10) = 1e-2; same for p_u.
533        // 5 is well inside [0.01, 9.9].
534        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 5.0, Some(0.0), Some(10.0));
535        assert!((v - 5.0).abs() < 1e-15);
536    }
537
538    #[test]
539    fn point_at_lower_bound_pushed_in() {
540        // x=0 at the lower bound. Should become lo + p_l = 0.01.
541        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 0.0, Some(0.0), Some(10.0));
542        assert!((v - 0.01).abs() < 1e-15);
543    }
544
545    #[test]
546    fn point_at_upper_bound_pushed_in() {
547        // x=10 at the upper bound. Should become hi - p_u = 9.9.
548        let v =
549            DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 10.0, Some(0.0), Some(10.0));
550        assert!((v - 9.9).abs() < 1e-15);
551    }
552
553    #[test]
554    fn point_below_lower_bound_clamped() {
555        // x=-5 → lo + p_l = 0.01.
556        let v =
557            DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, -5.0, Some(0.0), Some(10.0));
558        assert!((v - 0.01).abs() < 1e-15);
559    }
560
561    #[test]
562    fn lower_only_pushed_by_max_abs() {
563        // Lower-only with lo=-100: p_l = bound_push * max(|-100|, 1) = 1e-2 * 100 = 1.
564        // x=-100 → -100 + 1 = -99.
565        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, -100.0, Some(-100.0), None);
566        assert!((v - -99.0).abs() < 1e-13);
567    }
568
569    #[test]
570    fn upper_only_pushed_by_max_abs() {
571        // Upper-only with hi=50, x=50 → 50 - 1e-2 * 50 = 49.5.
572        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 50.0, None, Some(50.0));
573        assert!((v - 49.5).abs() < 1e-13);
574    }
575
576    #[test]
577    fn free_variable_unchanged() {
578        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 42.0, None, None);
579        assert_eq!(v, 42.0);
580    }
581
582    #[test]
583    fn narrow_interval_uses_bound_frac_branch() {
584        // Tiny span [0, 1e-4]: p_l = min(1e-2 * 1, 1e-2 * 1e-4) = 1e-6.
585        // x=0 → 0 + 1e-6 = 1e-6.
586        let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 0.0, Some(0.0), Some(1e-4));
587        assert!((v - 1e-6).abs() < 1e-18);
588    }
589}