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}