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` to zero, then revise them with the
16//! least-square estimate from
17//! [`crate::eq_mult::least_square::LeastSquareMults`] when an
18//! `EqMultCalculator` is wired and `constr_mult_init_max > 0`
19//! (an estimate above that cap is discarded, per upstream).
20//! 4. Initialize `z_l`, `z_u`, `v_l`, `v_u` to `bound_mult_init_val`
21//! (component-wise) — i.e. `bound_mult_init_method = "constant"`,
22//! the only mode pounce implements. `"mu-based"` is registered for
23//! `ipopt.opt` compatibility and refused rather than silently
24//! served as `"constant"` (gh#604).
25
26use crate::eq_mult::r#trait::EqMultCalculator;
27use crate::init::r#trait::IterateInitializer;
28use crate::ipopt_cq::IpoptCqHandle;
29use crate::ipopt_data::IpoptDataHandle;
30use crate::ipopt_nlp::IpoptNlp;
31use crate::iterates_vector::IteratesVector;
32use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
33use pounce_common::types::{Index, Number};
34use pounce_linalg::Vector;
35use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
36use std::cell::RefCell;
37use std::rc::Rc;
38
39/// What the safeguarded least-square initializer did. Returned by
40/// [`DefaultIterateInitializer::safeguarded_least_square_x`] and
41/// readable after the solve through
42/// [`crate::application::IpoptApplication::least_square_init_report`],
43/// so a starting point that silently got worse is visible somewhere
44/// other than the iteration count. It is primarily a programmatic
45/// accessor; since gh#616 the same fields are also emitted once per
46/// solve at `debug` level on the `pounce::algorithm` target, so a
47/// fixture sweep can attribute a moving model without a source patch
48/// (`RUST_LOG=pounce::algorithm=debug`). Nothing prints it at the
49/// default log level.
50#[derive(Debug, Clone, Default)]
51pub struct LeastSquareInitReport {
52 /// Nonlinear violation at the user's `x0`, after the interior push.
53 pub violation_initial: Number,
54 /// Nonlinear violation at the point actually handed to the
55 /// algorithm. Equal to `violation_initial` when nothing was
56 /// accepted.
57 pub violation_final: Number,
58 /// `alpha * ||x_ls - x0||_2` for the accepted trial; 0 if none was.
59 pub step_norm: Number,
60 /// Step fraction of the accepted trial; 0 if none was.
61 pub alpha: Number,
62 /// Backtracking trials whose true violation failed the test.
63 pub rejected_trials: Index,
64 /// Why the safeguard stopped.
65 pub termination: &'static str,
66}
67
68pub struct DefaultIterateInitializer {
69 pub bound_push: Number,
70 pub bound_frac: Number,
71 pub slack_bound_push: Number,
72 pub slack_bound_frac: Number,
73 pub constr_mult_init_max: Number,
74 pub bound_mult_init_val: Number,
75 /// `bound_mult_init_method`. Must be `"constant"` — upstream's
76 /// `"mu-based"` is registered for `ipopt.opt` compatibility but not
77 /// implemented, and `set_initial_iterates` returns `false` on it
78 /// rather than serving a third, undocumented behaviour (gh#604).
79 pub bound_mult_init_method: String,
80 /// Equality-multiplier calculator used by the
81 /// `least_square_mults` step at the end of `set_initial_iterates`,
82 /// matching upstream `IpDefaultIterateInitializer.cpp:334-341`. If
83 /// `None`, the LS step is skipped (y_c, y_d remain at zero).
84 pub eq_mult_calculator: Option<Box<dyn EqMultCalculator>>,
85 /// `least_square_init_primal` — port of
86 /// `IpDefaultIterateInitializer.cpp:200-222`. When on, the
87 /// initializer replaces the user's starting `x` with the min-norm
88 /// solution of the linearized equality + inequality constraints,
89 /// then pushes that to the interior. Used by the Mehrotra cascade
90 /// (`IpIpoptAlg.cpp:182`) to dramatically reduce iter-0 primal
91 /// infeasibility on LP-shaped problems.
92 pub least_square_init_primal: bool,
93 /// `least_square_init_primal_max_trials` — how many backtracking
94 /// trials the safeguard in [`Self::safeguarded_least_square_x`] may
95 /// take before it gives up and keeps the user's point. Each trial
96 /// costs one constraint evaluation (`c` and `d`); none of them
97 /// costs a Jacobian or a KKT solve, because the step direction is
98 /// computed once and only its length changes.
99 pub least_square_init_max_trials: Index,
100 /// Armijo-style acceptance ratio for the safeguard: a trial at
101 /// step fraction `alpha` is accepted when the true nonlinear
102 /// violation satisfies `theta(alpha) <= (1 - eta*alpha) * theta_0`,
103 /// i.e. when the *actual* feasibility reduction is at least `eta`
104 /// times the reduction the linearization *predicted*.
105 pub least_square_init_accept_ratio: Number,
106 /// Diagnostics from the most recent safeguarded least-square
107 /// initialization: initial/final violation, accepted step norm,
108 /// rejected trial count, termination reason. `None` when the step
109 /// was never attempted.
110 pub last_least_square_report: Option<LeastSquareInitReport>,
111}
112
113impl Default for DefaultIterateInitializer {
114 fn default() -> Self {
115 Self {
116 bound_push: 1e-2,
117 bound_frac: 1e-2,
118 slack_bound_push: 1e-2,
119 slack_bound_frac: 1e-2,
120 constr_mult_init_max: 1e3,
121 bound_mult_init_val: 1.0,
122 bound_mult_init_method: "constant".into(),
123 eq_mult_calculator: None,
124 least_square_init_primal: false,
125 least_square_init_max_trials: 4,
126 least_square_init_accept_ratio: 1e-2,
127 last_least_square_report: None,
128 }
129 }
130}
131
132impl DefaultIterateInitializer {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn with_eq_mult_calculator(eq_mult: Box<dyn EqMultCalculator>) -> Self {
138 Self {
139 eq_mult_calculator: Some(eq_mult),
140 ..Self::default()
141 }
142 }
143
144 /// Per-element bound-push formula from upstream
145 /// `IpDefaultIterateInitializer.cpp:473-666`. Given a primal value
146 /// `x` and optional bounds `(lower, upper)`, return a value
147 /// shifted to the interior:
148 ///
149 /// * Two-sided bounds: clamp into `[lo + p_l, hi - p_u]` where
150 /// `p_l = min(bound_push * max(|lo|, 1), bound_frac * (hi-lo))`,
151 /// `p_u = min(bound_push * max(|hi|, 1), bound_frac * (hi-lo))`.
152 /// * Lower-only: return `max(x, lo + bound_push * max(|lo|, 1))`.
153 /// * Upper-only: return `min(x, hi - bound_push * max(|hi|, 1))`.
154 /// * Free: return `x`.
155 ///
156 /// The `Px_L`/`Px_U` selection-matrix dance in upstream collapses
157 /// to exactly this per-coordinate formula once the bounds are
158 /// expanded to the full primal space.
159 /// Port of `IpDefaultIterateInitializer.cpp:CalculateLeastSquarePrimals`.
160 /// Solves the augmented system with `W=0`, `D_x=I`, `D_s=I` and
161 /// `rhs=(0, 0, curr_c, curr_d)`; on success returns the min-norm
162 /// `x_ls` (negated per upstream `x_ls.Scal(-1)`). `s_ls` is
163 /// discarded — upstream overwrites it with `trial_d(trial_x)`
164 /// after pushing `x_ls` to the interior, so we save the allocation
165 /// and re-evaluate `d` later in `set_initial_iterates`. Assumes
166 /// `data.curr.x` already holds the point at which the constraints
167 /// and Jacobians should be linearized.
168 fn calculate_least_square_primals(
169 &self,
170 cq: &IpoptCqHandle,
171 nlp: &Rc<RefCell<dyn IpoptNlp>>,
172 aug_solver: &mut dyn AugSystemSolver,
173 n_x: Index,
174 ) -> Option<Rc<dyn Vector>> {
175 let cq_ref = cq.borrow();
176 let curr_c = cq_ref.curr_c();
177 let curr_d = cq_ref.curr_d();
178 let j_c = cq_ref.curr_jac_c();
179 let j_d = cq_ref.curr_jac_d();
180 // `zeroW` pins the W triplet structure in the linsol so later
181 // calls with the real Hessian write into the right slots
182 // (mirrors `IpLeastSquareMults`). Structure only — see the note
183 // in `eq_mult/least_square.rs`; evaluating the Hessian here asked
184 // a limited-memory NLP for a callback it may not have (gh#698).
185 let zero_w = nlp.borrow().uninitialized_h();
186 drop(cq_ref);
187
188 let n_s = curr_d.dim();
189 let n_c = curr_c.dim();
190 let n_d = curr_d.dim();
191
192 let mut rhs_x = DenseVectorSpace::new(n_x).make_new_dense();
193 rhs_x.set(0.0);
194 let mut rhs_s = DenseVectorSpace::new(n_s).make_new_dense();
195 rhs_s.set(0.0);
196 let mut rhs_c_v = curr_c.make_new();
197 rhs_c_v.copy(&*curr_c);
198 let mut rhs_d_v = curr_d.make_new();
199 rhs_d_v.copy(&*curr_d);
200
201 let mut sol_x = DenseVectorSpace::new(n_x).make_new_dense();
202 let mut sol_s = DenseVectorSpace::new(n_s).make_new_dense();
203 let mut sol_c = DenseVectorSpace::new(n_c).make_new_dense();
204 let mut sol_d = DenseVectorSpace::new(n_d).make_new_dense();
205
206 let coeffs = AugSysCoeffs {
207 w: Some(&*zero_w),
208 w_factor: 0.0,
209 d_x: None,
210 delta_x: 1.0,
211 d_s: None,
212 delta_s: 1.0,
213 j_c: &*j_c,
214 d_c: None,
215 // Tiny δ_c, δ_d (upstream uses 0). pounce-feral's LDL^T
216 // mis-reports the inertia of an augmented system with a
217 // structurally-zero (3,3)/(4,4) block — it counted 0
218 // negative eigenvalues on nuffield2_trap where the true
219 // count is n_c+n_d, triggering WrongInertia. Perturbing
220 // by 1e-8 keeps the LS solution numerically identical
221 // (the constraint Jacobian dominates this term) while
222 // giving the diagonal something nonzero to pivot on.
223 delta_c: 1e-8,
224 j_d: &*j_d,
225 d_d: None,
226 delta_d: 1e-8,
227 };
228 let aug_rhs = AugSysRhs {
229 rhs_x: &rhs_x,
230 rhs_s: &rhs_s,
231 rhs_c: &*rhs_c_v,
232 rhs_d: &*rhs_d_v,
233 };
234 let mut sol = AugSysSol {
235 sol_x: &mut sol_x,
236 sol_s: &mut sol_s,
237 sol_c: &mut sol_c,
238 sol_d: &mut sol_d,
239 };
240
241 // Upstream `IpDefaultIterateInitializer.cpp:381` passes
242 // check_NegEVals=true, numberOfNegEVals=n_c+n_d (matches the
243 // expected inertia of the W=0,Dx=I,Ds=I augmented system).
244 let num_eq = n_c + n_d;
245 let check_neg = aug_solver.provides_inertia();
246 let status = aug_solver.solve(&coeffs, &aug_rhs, &mut sol, check_neg, num_eq);
247 if !matches!(status, pounce_linsol::ESymSolverStatus::Success) {
248 return None;
249 }
250 // Upstream `IpDefaultIterateInitializer.cpp:386-387`:
251 // x_ls.Scal(-1); s_ls.Scal(-1).
252 sol_x.scal(-1.0);
253 Some(Rc::new(sol_x))
254 }
255
256 /// The safeguard's accept test, as a pure predicate of the four
257 /// numbers it actually reads.
258 ///
259 /// A trial at step fraction `alpha` whose true nonlinear violation
260 /// is `theta` is accepted when
261 /// `theta <= (1 - eta*alpha) * theta_0` — the linear model predicts
262 /// `theta -> 0` at `alpha = 1`, so the predicted reduction at
263 /// `alpha` is `alpha * theta_0` and the test is exactly "the actual
264 /// reduction is at least `eta` times the predicted one". The
265 /// trailing `theta < theta_0` makes the contract independent of
266 /// `eta`: however small `eta` is set, a trial that does not strictly
267 /// reduce the violation is never accepted.
268 ///
269 /// Split out of the trial loop for gh#616, whose conclusion rests on
270 /// what this predicate *can* express. `eta` is confined to `(0, 1]`
271 /// — at `eta > 1` the `alpha = 1` trial would demand a negative
272 /// violation and the full step could never be taken — and over that
273 /// whole range the reachable rejections are bounded. The tests in
274 /// `tests/issue_616_ls_init_accept_test.rs` pin the consequence: no
275 /// `eta` rejects `eigenb2`'s accepted trial, so retuning `eta` is
276 /// not a route to its old `SolveSucceeded` status.
277 pub fn accepts_trial(theta_0: Number, theta: Number, alpha: Number, eta: Number) -> bool {
278 let predicted = alpha * theta_0;
279 let actual = theta_0 - theta;
280 theta.is_finite() && actual >= eta * predicted && theta < theta_0
281 }
282
283 /// Stage `x_cand` as the current iterate and return the true
284 /// nonlinear constraint violation there.
285 ///
286 /// The merit is `curr_unscaled_nlp_constraint_violation_max()` —
287 /// `max(||c(x)||_inf, ||max(d_l - d(x), d(x) - d_u, 0)||_inf)` in
288 /// unscaled NLP units. It is the same quantity the CLI reports as
289 /// the model's constraint violation, so "the initializer improved
290 /// feasibility" means the number a user can read improved.
291 ///
292 /// Costs one `c`/`d` evaluation. No Jacobian, no KKT solve.
293 fn violation_at(
294 data: &IpoptDataHandle,
295 cq: &IpoptCqHandle,
296 template: &IteratesVector,
297 x_cand: &dyn Vector,
298 ) -> Number {
299 let mut x_stage = DenseVectorSpace::new(x_cand.dim()).make_new_dense();
300 x_stage.copy(x_cand);
301 let staged = template.with_x(Rc::new(x_stage));
302 data.borrow_mut().set_curr(staged);
303 cq.borrow().curr_unscaled_nlp_constraint_violation_max()
304 }
305
306 /// Safeguarded least-square normal step.
307 ///
308 /// `calculate_least_square_primals` returns the minimum-norm
309 /// solution of the *linearized* constraints. That is a local model
310 /// step, not automatically a better NLP starting point: where the
311 /// Jacobian is small relative to the residual the linearization
312 /// asks for a huge correction, and the true nonlinear violation at
313 /// the far end can be orders of magnitude worse than where it
314 /// started. Accepting it unconditionally (which is what upstream
315 /// `IpDefaultIterateInitializer.cpp:200-222` does, and what pounce
316 /// did through 0.10.0) hands the algorithm a worse starting point
317 /// than the user supplied.
318 ///
319 /// So: compute the direction once, then walk it back.
320 ///
321 /// * Trial `k` uses `alpha = 2^-k` for `k` in `0..max_trials`.
322 /// * Every candidate is pushed into the bound interior *before*
323 /// its violation is measured, so the accepted merit is the merit
324 /// of the point the algorithm will actually start from, and
325 /// bound interiority is preserved by construction.
326 /// * A trial is accepted when
327 /// `theta(alpha) <= (1 - eta*alpha) * theta_0`. The linear model
328 /// predicts `theta -> 0` at `alpha = 1`, so the predicted
329 /// reduction at `alpha` is `alpha * theta_0` and this test is
330 /// exactly "actual reduction is at least `eta` times predicted".
331 /// * The first accepted trial wins (they are tried longest-first,
332 /// so that is also the best available reduction on this ray).
333 /// * If no trial is accepted the user's `x` is returned unchanged.
334 ///
335 /// # What the safeguard does *not* promise (gh#616)
336 ///
337 /// The guarantee above is about **the starting point's violation** —
338 /// the only quantity the test measures. It says nothing about the
339 /// trajectory that follows. A more feasible starting point on a
340 /// nonconvex model is entitled to reach a different local minimum,
341 /// and to converge into a different tolerance band. Two fixtures do
342 /// exactly that under `least_square_init_primal=yes`: `csfi2` and
343 /// `eigenb2` end at `SolvedToAcceptableLevel` where the
344 /// unsafeguarded step reached `SolveSucceeded`.
345 ///
346 /// gh#616 attributed every moving fixture through
347 /// [`LeastSquareInitReport`] and established that this is not a
348 /// defect in the accept test, and cannot be repaired by tightening
349 /// it. See `docs/src/initialization.md` for the measurements. The
350 /// two facts that decide it:
351 ///
352 /// * `csfi2` **declines** — all four trials are worse than `theta_0`.
353 /// Recovering its old status would require accepting a step that
354 /// *increases* the true violation, which is the one thing this
355 /// function exists to prevent. No tightening reaches it.
356 /// * `eigenb2` **accepts** at `alpha = 0.5`, cutting the violation
357 /// `1.0 -> 0.25`. `eigena2` accepts on bit-identical numbers —
358 /// same `theta_0`, same `theta`, same `alpha`, same step norm —
359 /// and *improves* (78 -> 65 iterations). Any criterion computed
360 /// from this function's own inputs necessarily treats the two
361 /// alike, so none can keep the `eigena2` win and drop the
362 /// `eigenb2` step.
363 ///
364 /// Also worth knowing before reading `least_square_init_primal=yes`
365 /// results: a **declined** step is not the same as never asking.
366 /// Declining restores the user's `x` exactly, but
367 /// `calculate_least_square_primals` has by then driven the first
368 /// factorization through the augmented-system solver, on the
369 /// `W = 0` least-square matrix rather than on the first real KKT
370 /// matrix. gh#616 isolated this by forcing a decline on either side
371 /// of that call: declining *before* it is bit-identical to
372 /// `least_square_init_primal=no` on every fixture, declining *after*
373 /// it is bit-identical to the real safeguard. On `pooling_rt2stp`
374 /// the carried-over state is worth 298 -> 81 iterations, on `deb7`
375 /// 154 -> 202.
376 ///
377 /// Returns `(accepted_x, diagnostics)`.
378 #[allow(clippy::too_many_arguments)]
379 fn safeguarded_least_square_x(
380 &self,
381 data: &IpoptDataHandle,
382 cq: &IpoptCqHandle,
383 nlp: &Rc<RefCell<dyn IpoptNlp>>,
384 aug_solver: &mut dyn AugSystemSolver,
385 template: &IteratesVector,
386 x0: &dyn Vector,
387 n_x: Index,
388 ) -> (Option<Box<dyn Vector>>, LeastSquareInitReport) {
389 let mut report = LeastSquareInitReport::default();
390
391 // theta_0 at the user's point, measured after the interior
392 // push so it is comparable with every trial below.
393 let mut x_base = DenseVectorSpace::new(n_x).make_new_dense();
394 x_base.copy(x0);
395 self.push_into_bounds(nlp, &mut x_base);
396 let theta_0 = Self::violation_at(data, cq, template, &x_base);
397 report.violation_initial = theta_0;
398 report.violation_final = theta_0;
399
400 if !theta_0.is_finite() {
401 report.termination = "x0 violation is not finite";
402 return (None, report);
403 }
404 if theta_0 == 0.0 {
405 report.termination = "x0 already feasible";
406 return (None, report);
407 }
408
409 // The direction. Computed at the user's point, so re-stage it
410 // first: `calculate_least_square_primals` linearizes at
411 // whatever `data.curr` holds.
412 let staged = template.with_x({
413 let mut xs = DenseVectorSpace::new(n_x).make_new_dense();
414 xs.copy(x0);
415 Rc::new(xs)
416 });
417 data.borrow_mut().set_curr(staged);
418 let x_ls = match self.calculate_least_square_primals(cq, nlp, aug_solver, n_x) {
419 Some(v) => v,
420 None => {
421 report.termination = "augmented system solve failed";
422 return (None, report);
423 }
424 };
425
426 // d = x_ls - x0, formed once and reused at every trial.
427 let mut dir = DenseVectorSpace::new(n_x).make_new_dense();
428 dir.copy(&*x_ls);
429 dir.axpy(-1.0, x0);
430 let dir_norm = dir.nrm2();
431 if !dir_norm.is_finite() {
432 report.termination = "least-square step is not finite";
433 return (None, report);
434 }
435
436 let mut alpha = 1.0;
437 for _ in 0..self.least_square_init_max_trials.max(1) {
438 let mut cand = DenseVectorSpace::new(n_x).make_new_dense();
439 if alpha == 1.0 {
440 // Use `x_ls` itself rather than `x0 + 1.0*(x_ls - x0)`.
441 // The two differ in the last bit, and that is enough to
442 // move a borderline model by an iteration — so the
443 // full-length trial stays bit-identical to what the
444 // unsafeguarded path produced, and the only trajectory
445 // change is on the models where the step is actually
446 // rejected.
447 cand.copy(&*x_ls);
448 } else {
449 cand.copy(x0);
450 cand.axpy(alpha, &dir);
451 }
452 self.push_into_bounds(nlp, &mut cand);
453
454 let theta = Self::violation_at(data, cq, template, &cand);
455 if Self::accepts_trial(theta_0, theta, alpha, self.least_square_init_accept_ratio) {
456 report.violation_final = theta;
457 report.step_norm = alpha * dir_norm;
458 report.alpha = alpha;
459 report.termination = "accepted";
460 return (Some(Box::new(cand)), report);
461 }
462 report.rejected_trials += 1;
463 alpha *= 0.5;
464 }
465
466 report.termination = "no trial improved the nonlinear violation";
467 (None, report)
468 }
469
470 /// Push `x` into the interior of `[x_l, x_u]` with this
471 /// initializer's `bound_push` / `bound_frac`. Split out so the
472 /// safeguard can measure candidates at the point the algorithm
473 /// would actually use.
474 fn push_into_bounds(&self, nlp: &Rc<RefCell<dyn IpoptNlp>>, x: &mut DenseVector) {
475 let nlp_ref = nlp.borrow();
476 push_x_into_interior(
477 x,
478 &*nlp_ref.px_l(),
479 nlp_ref.x_l(),
480 &*nlp_ref.px_u(),
481 nlp_ref.x_u(),
482 self.bound_push,
483 self.bound_frac,
484 );
485 }
486
487 pub fn push_to_interior(
488 bound_push: Number,
489 bound_frac: Number,
490 x: Number,
491 lower: Option<Number>,
492 upper: Option<Number>,
493 ) -> Number {
494 match (lower, upper) {
495 (Some(lo), Some(hi)) => {
496 let span = hi - lo;
497 let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
498 let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
499 x.max(lo + p_l).min(hi - p_u)
500 }
501 (Some(lo), None) => {
502 let p_l = bound_push * lo.abs().max(1.0);
503 x.max(lo + p_l)
504 }
505 (None, Some(hi)) => {
506 let p_u = bound_push * hi.abs().max(1.0);
507 x.min(hi - p_u)
508 }
509 (None, None) => x,
510 }
511 }
512}
513
514impl IterateInitializer for DefaultIterateInitializer {
515 fn least_square_report(&self) -> Option<LeastSquareInitReport> {
516 self.last_least_square_report.clone()
517 }
518
519 fn set_initial_iterates(
520 &mut self,
521 data: &IpoptDataHandle,
522 cq: &IpoptCqHandle,
523 nlp: &Rc<RefCell<dyn IpoptNlp>>,
524 aug_solver: &mut dyn AugSystemSolver,
525 ) -> bool {
526 let curr_template = match data.borrow().curr.clone() {
527 Some(c) => c,
528 None => return false,
529 };
530
531 let n_x = curr_template.x.dim();
532 let n_s = curr_template.s.dim();
533 let n_yc = curr_template.y_c.dim();
534 let n_yd = curr_template.y_d.dim();
535 let n_zl = curr_template.z_l.dim();
536 let n_zu = curr_template.z_u.dim();
537 let n_vl = curr_template.v_l.dim();
538 let n_vu = curr_template.v_u.dim();
539
540 // Step 1: pull x from NLP and push each finite-bounded
541 // component into the interior. Bound vectors `x_l`, `x_u` are
542 // packed (only finite entries); we expand via `Px_L^T` masks
543 // by walking the dense slot.
544 let mut x = DenseVectorSpace::new(n_x).make_new_dense();
545 nlp.borrow_mut().get_starting_x(&mut x);
546
547 // Step 1.5 (optional): replace `x` with the min-norm solution
548 // of the linearized equality + inequality constraints. Port of
549 // `IpDefaultIterateInitializer.cpp:200-222`. The Mehrotra
550 // cascade in `application.rs` turns this on; it is the iter-0
551 // feasibility correction that lets Mehrotra LPs land on the
552 // central path on the first solve. Failure leaves `x` as-is.
553 if self.least_square_init_primal && (n_yc + n_yd) > 0 {
554 // Stage a partial iterate with the user's starting `x` and
555 // zeros for everything else, so `cq.curr_*` evaluates at
556 // the right point.
557 let mut x_stage = DenseVectorSpace::new(n_x).make_new_dense();
558 x_stage.copy(&x);
559 let mut s_zero = DenseVectorSpace::new(n_s).make_new_dense();
560 s_zero.set(0.0);
561 let mut y_c_zero = DenseVectorSpace::new(n_yc).make_new_dense();
562 y_c_zero.set(0.0);
563 let mut y_d_zero = DenseVectorSpace::new(n_yd).make_new_dense();
564 y_d_zero.set(0.0);
565 let mut z_l_zero = DenseVectorSpace::new(n_zl).make_new_dense();
566 z_l_zero.set(0.0);
567 let mut z_u_zero = DenseVectorSpace::new(n_zu).make_new_dense();
568 z_u_zero.set(0.0);
569 let mut v_l_zero = DenseVectorSpace::new(n_vl).make_new_dense();
570 v_l_zero.set(0.0);
571 let mut v_u_zero = DenseVectorSpace::new(n_vu).make_new_dense();
572 v_u_zero.set(0.0);
573 let stage_iv = IteratesVector::new(
574 Rc::new(x_stage),
575 Rc::new(s_zero),
576 Rc::new(y_c_zero),
577 Rc::new(y_d_zero),
578 Rc::new(z_l_zero),
579 Rc::new(z_u_zero),
580 Rc::new(v_l_zero),
581 Rc::new(v_u_zero),
582 );
583 data.borrow_mut().set_curr(stage_iv.clone());
584
585 // The linearized least-squares point is only accepted when
586 // it actually reduces the *true* nonlinear violation; see
587 // `safeguarded_least_square_x`. Rejecting it leaves `x` as
588 // the user gave it, which is the pre-0.11 behaviour minus
589 // the cases where the linearization sent the starting
590 // point somewhere worse.
591 let (accepted, report) =
592 self.safeguarded_least_square_x(data, cq, nlp, aug_solver, &stage_iv, &x, n_x);
593 if let Some(x_new) = accepted {
594 x.copy(&*x_new);
595 }
596 // Attribution for a fixture sweep (gh#616). The accessor
597 // alone is unreachable from the CLI, so working out *which*
598 // arm of the safeguard moved a given model meant patching
599 // this file and rebuilding — which is how gh#616's
600 // measurement was taken, and is not a thing the next
601 // reader should have to repeat. `RUST_LOG=pounce::
602 // algorithm=debug` now prints it. Nothing at the default
603 // level does: it is one line per solve, not per iteration,
604 // and it stays off the normal log.
605 tracing::debug!(
606 target: "pounce::algorithm",
607 violation_initial = report.violation_initial,
608 violation_final = report.violation_final,
609 alpha = report.alpha,
610 step_norm = report.step_norm,
611 rejected_trials = report.rejected_trials,
612 termination = report.termination,
613 "pounce: least_square_init_primal safeguard decision",
614 );
615 self.last_least_square_report = Some(report);
616 }
617
618 {
619 let nlp_ref = nlp.borrow();
620 push_x_into_interior(
621 &mut x,
622 &*nlp_ref.px_l(),
623 nlp_ref.x_l(),
624 &*nlp_ref.px_u(),
625 nlp_ref.x_u(),
626 self.bound_push,
627 self.bound_frac,
628 );
629 }
630
631 // Step 2: s = d(x), then push into [d_l, d_u].
632 let mut s = DenseVectorSpace::new(n_s).make_new_dense();
633 nlp.borrow_mut().eval_d(&x, &mut s);
634 {
635 let nlp_ref = nlp.borrow();
636 push_x_into_interior(
637 &mut s,
638 &*nlp_ref.pd_l(),
639 nlp_ref.d_l(),
640 &*nlp_ref.pd_u(),
641 nlp_ref.d_u(),
642 self.slack_bound_push,
643 self.slack_bound_frac,
644 );
645 }
646
647 // `bound_mult_init_method` — pounce implements `constant` only
648 // (gh#604). The refusal that a caller actually sees is raised at
649 // the application layer, before any work
650 // (`unimplemented_options::UNIMPLEMENTED_VALUES`); this is the
651 // backstop for a caller who builds the initializer directly.
652 //
653 // It used to fall through to `nlp.get_starting_y` here, which is
654 // neither of the documented modes — an unsupported value silently
655 // bought a *third* behaviour. Failing is the honest answer.
656 if !self.bound_mult_init_method.eq_ignore_ascii_case("constant") {
657 tracing::error!(
658 target: "pounce::algorithm",
659 method = %self.bound_mult_init_method,
660 "pounce: bound_mult_init_method must be \"constant\"; \
661 \"mu-based\" is registered for ipopt.opt compatibility but \
662 not implemented (gh#604)."
663 );
664 return false;
665 }
666
667 // Step 3: y_c, y_d initial guesses. `constant` mode leaves
668 // them at zero (the algorithm refines on the first KKT solve),
669 // and the least-square step below revises them when an
670 // `EqMultCalculator` is wired.
671 let mut y_c = DenseVectorSpace::new(n_yc).make_new_dense();
672 let mut y_d = DenseVectorSpace::new(n_yd).make_new_dense();
673 // Materialize as homogeneous-zero so callers' asum / values
674 // probes don't trip the `initialized` debug-assert.
675 y_c.set(0.0);
676 y_d.set(0.0);
677
678 // Step 4: bound multipliers — constant init.
679 let mut z_l = DenseVectorSpace::new(n_zl).make_new_dense();
680 let mut z_u = DenseVectorSpace::new(n_zu).make_new_dense();
681 let mut v_l = DenseVectorSpace::new(n_vl).make_new_dense();
682 let mut v_u = DenseVectorSpace::new(n_vu).make_new_dense();
683 z_l.set(self.bound_mult_init_val);
684 z_u.set(self.bound_mult_init_val);
685 v_l.set(self.bound_mult_init_val);
686 v_u.set(self.bound_mult_init_val);
687
688 let iv = IteratesVector::new(
689 Rc::new(x),
690 Rc::new(s),
691 Rc::new(y_c),
692 Rc::new(y_d),
693 Rc::new(z_l),
694 Rc::new(z_u),
695 Rc::new(v_l),
696 Rc::new(v_u),
697 );
698 let n_x_dim = iv.x.dim();
699 data.borrow_mut().set_curr(iv);
700
701 // Step 5: least-square equality multipliers — port of
702 // `IpDefaultIterateInitializer.cpp:285-341` /
703 // `least_square_mults` (lines 669-743). Upstream always runs
704 // this after the constant-init y_c/y_d=0, unless the full
705 // `least_square_init_duals` path succeeded. Without it the
706 // initial gradient-of-Lagrangian residual is computed against
707 // y_c=y_d=0, blowing up `inf_du` on iter 0.
708 if n_yc != n_x_dim
709 && self.constr_mult_init_max > 0.0
710 && (n_yc + n_yd) > 0
711 && self.eq_mult_calculator.is_some()
712 {
713 let mut new_y_c = DenseVectorSpace::new(n_yc).make_new_dense();
714 let mut new_y_d = DenseVectorSpace::new(n_yd).make_new_dense();
715 let calc = self.eq_mult_calculator.as_mut().unwrap();
716 let ok = calc.calculate_y_eq(data, cq, nlp, aug_solver, &mut new_y_c, &mut new_y_d);
717 if !ok {
718 // Solver failed → leave at zero (already the case).
719 data.borrow_mut().append_info_string("y0");
720 } else {
721 let yinitnrm = new_y_c.amax().max(new_y_d.amax());
722 if yinitnrm > self.constr_mult_init_max {
723 // Cap exceeded → upstream zeros them out
724 // (`IpDefaultIterateInitializer.cpp:723-727`).
725 data.borrow_mut().append_info_string("yc");
726 } else {
727 // Accept LS estimates. Build a fresh iterate
728 // sharing the existing x/s/z/v Rcs and replacing
729 // y_c, y_d with the LS values.
730 let curr = data.borrow().curr.clone();
731 if let Some(c) = curr {
732 let new_iv = IteratesVector::new(
733 c.x.clone(),
734 c.s.clone(),
735 Rc::new(new_y_c),
736 Rc::new(new_y_d),
737 c.z_l.clone(),
738 c.z_u.clone(),
739 c.v_l.clone(),
740 c.v_u.clone(),
741 );
742 let mut d = data.borrow_mut();
743 d.set_curr(new_iv);
744 d.append_info_string("y");
745 }
746 }
747 }
748 }
749
750 true
751 }
752}
753
754/// Apply [`DefaultIterateInitializer::push_to_interior`] to every
755/// component of `x` using the lower/upper bound vectors expanded
756/// through the `P_L`/`P_U` selection matrices. Bounds are packed
757/// (lower-bound vector `x_l` has dim equal to the number of
758/// lower-bounded components; `Px_L: n × n_lo` selects them).
759pub(crate) fn push_x_into_interior(
760 x: &mut DenseVector,
761 px_l: &dyn pounce_linalg::Matrix,
762 x_l: &dyn Vector,
763 px_u: &dyn pounce_linalg::Matrix,
764 x_u: &dyn Vector,
765 bound_push: Number,
766 bound_frac: Number,
767) {
768 // Use `dim()` (not `values().len()`): the iterate initializer is
769 // called before any user `x0` has been written, so `x` is still in
770 // its default homogeneous-zero state. `values()` carries a
771 // `debug_assert!(!self.homogeneous)` and trips in debug builds on
772 // clnlbeam.nl-class problems (n=59999, x_L/x_U packed). `values_mut()`
773 // below materializes the dense buffer before the per-element write.
774 let n = x.dim() as usize;
775 // Expand x_l and x_u into full-length sentinel vectors:
776 // lower[i] = Some(x_l_packed[k]) if i is the k-th lower-bounded slot
777 // upper[i] = Some(x_u_packed[k]) similarly.
778 let mut lower = vec![None; n];
779 let mut upper = vec![None; n];
780 expand_packed_into_dense(px_l, x_l, &mut lower);
781 expand_packed_into_dense(px_u, x_u, &mut upper);
782
783 let xs = x.values_mut();
784 for (i, xi) in xs.iter_mut().enumerate() {
785 *xi = DefaultIterateInitializer::push_to_interior(
786 bound_push, bound_frac, *xi, lower[i], upper[i],
787 );
788 }
789}
790
791/// Apply `P` to a packed bound vector `b_packed` (dim `n_pack`) to
792/// produce a sparse marking of `out` (dim `P.n_rows`). For each
793/// `k = 0..n_pack`, `out[P_rows[k]] = Some(b_packed[k])`. Falls back
794/// to a column-by-column probe via `mult_vector` if downcast to
795/// `ExpansionMatrix` is unavailable.
796fn expand_packed_into_dense(
797 p: &dyn pounce_linalg::Matrix,
798 b_packed: &dyn Vector,
799 out: &mut [Option<Number>],
800) {
801 use pounce_linalg::expansion_matrix::ExpansionMatrix;
802 let dim_packed = b_packed.dim() as usize;
803 if dim_packed == 0 {
804 return;
805 }
806
807 if let Some(em) = p.as_any().downcast_ref::<ExpansionMatrix>() {
808 let rows = em.expanded_pos_indices();
809 let Some(packed) = b_packed.as_any().downcast_ref::<DenseVector>() else {
810 unreachable!("expansion-matrix bound vec must be DenseVector")
811 };
812 let vals = packed.values();
813 for k in 0..dim_packed {
814 let row = rows[k] as usize;
815 out[row] = Some(vals[k]);
816 }
817 } else {
818 // Generic fallback: probe via mult_vector with unit input
819 // vectors. Quadratic; fine for tiny problems and tests.
820 let n_full = out.len() as i32;
821 let mut tmp = DenseVectorSpace::new(n_full).make_new_dense();
822 for k in 0..dim_packed {
823 let mut e_k = DenseVectorSpace::new(b_packed.dim()).make_new_dense();
824 e_k.values_mut()[k] = 1.0;
825 tmp.set(0.0);
826 p.mult_vector(1.0, &e_k, 0.0, &mut tmp);
827 // tmp is the k-th expansion column: a single 1.0 at the
828 // expanded position. Read the value we want into the
829 // matching slot.
830 let Some(packed) = b_packed.as_any().downcast_ref::<DenseVector>() else {
831 unreachable!("packed bound vec must be DenseVector")
832 };
833 for (i, &t) in tmp.values().iter().enumerate() {
834 if t == 1.0 {
835 out[i] = Some(packed.values()[k]);
836 }
837 }
838 }
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn interior_point_left_alone() {
848 // x=5 strictly inside [0, 10] with bound_push=1e-2 →
849 // p_l = min(1e-2 * max(0,1), 1e-2 * 10) = 1e-2; same for p_u.
850 // 5 is well inside [0.01, 9.9].
851 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 5.0, Some(0.0), Some(10.0));
852 assert!((v - 5.0).abs() < 1e-15);
853 }
854
855 #[test]
856 fn point_at_lower_bound_pushed_in() {
857 // x=0 at the lower bound. Should become lo + p_l = 0.01.
858 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 0.0, Some(0.0), Some(10.0));
859 assert!((v - 0.01).abs() < 1e-15);
860 }
861
862 #[test]
863 fn point_at_upper_bound_pushed_in() {
864 // x=10 at the upper bound. Should become hi - p_u = 9.9.
865 let v =
866 DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 10.0, Some(0.0), Some(10.0));
867 assert!((v - 9.9).abs() < 1e-15);
868 }
869
870 #[test]
871 fn point_below_lower_bound_clamped() {
872 // x=-5 → lo + p_l = 0.01.
873 let v =
874 DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, -5.0, Some(0.0), Some(10.0));
875 assert!((v - 0.01).abs() < 1e-15);
876 }
877
878 #[test]
879 fn lower_only_pushed_by_max_abs() {
880 // Lower-only with lo=-100: p_l = bound_push * max(|-100|, 1) = 1e-2 * 100 = 1.
881 // x=-100 → -100 + 1 = -99.
882 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, -100.0, Some(-100.0), None);
883 assert!((v - -99.0).abs() < 1e-13);
884 }
885
886 #[test]
887 fn upper_only_pushed_by_max_abs() {
888 // Upper-only with hi=50, x=50 → 50 - 1e-2 * 50 = 49.5.
889 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 50.0, None, Some(50.0));
890 assert!((v - 49.5).abs() < 1e-13);
891 }
892
893 #[test]
894 fn free_variable_unchanged() {
895 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 42.0, None, None);
896 assert_eq!(v, 42.0);
897 }
898
899 #[test]
900 fn narrow_interval_uses_bound_frac_branch() {
901 // Tiny span [0, 1e-4]: p_l = min(1e-2 * 1, 1e-2 * 1e-4) = 1e-6.
902 // x=0 → 0 + 1e-6 = 1e-6.
903 let v = DefaultIterateInitializer::push_to_interior(1e-2, 1e-2, 0.0, Some(0.0), Some(1e-4));
904 assert!((v - 1e-6).abs() < 1e-18);
905 }
906}
907
908/// Behavior tests for the cold-start options (gh#604).
909///
910/// The wiring tests in `tests/init_options_wiring.rs` prove each option
911/// reaches [`crate::alg_builder::InitOptions`]; these prove the value
912/// then changes the iterate the initializer produces. An option that is
913/// registered, read, threaded onto the strategy and then ignored is the
914/// same silent no-op with more steps.
915#[cfg(test)]
916mod option_behavior {
917 use super::*;
918 use crate::ipopt_cq::IpoptCalculatedQuantities;
919 use crate::ipopt_data::IpoptData;
920 use pounce_linalg::{IdentityMatrix, Matrix, SymMatrix};
921 use pounce_linsol::status::ESymSolverStatus;
922
923 const N_X: Index = 2;
924 const N_S: Index = 1;
925 const N_C: Index = 1;
926
927 /// `x0 = [0, 10]` sits on both bounds of `[0, 10]^2`, and
928 /// `d(x) = -5` sits on the lower inequality bound, so every push
929 /// knob has something visible to move.
930 struct StubNlp {
931 x_l: DenseVector,
932 x_u: DenseVector,
933 d_l: DenseVector,
934 d_u: DenseVector,
935 p2: Rc<dyn Matrix>,
936 p1: Rc<dyn Matrix>,
937 }
938
939 impl StubNlp {
940 fn new() -> Self {
941 let s2 = DenseVectorSpace::new(N_X);
942 let mut x_l = DenseVector::new(Rc::clone(&s2));
943 x_l.set_values(&[0.0, 0.0]);
944 let mut x_u = DenseVector::new(Rc::clone(&s2));
945 x_u.set_values(&[10.0, 10.0]);
946 let s1 = DenseVectorSpace::new(N_S);
947 let mut d_l = DenseVector::new(Rc::clone(&s1));
948 d_l.set_values(&[-5.0]);
949 let mut d_u = DenseVector::new(Rc::clone(&s1));
950 d_u.set_values(&[5.0]);
951 Self {
952 x_l,
953 x_u,
954 d_l,
955 d_u,
956 p2: Rc::new(IdentityMatrix::new(N_X)),
957 p1: Rc::new(IdentityMatrix::new(N_S)),
958 }
959 }
960 }
961
962 impl crate::ipopt_nlp::Nlp for StubNlp {
963 fn n(&self) -> Index {
964 N_X
965 }
966 fn m_eq(&self) -> Index {
967 N_C
968 }
969 fn m_ineq(&self) -> Index {
970 N_S
971 }
972 fn eval_f(&mut self, _x: &dyn Vector) -> Number {
973 0.0
974 }
975 fn eval_grad_f(&mut self, _x: &dyn Vector, g: &mut dyn Vector) {
976 g.set(0.0);
977 }
978 /// `c(x) = x0 + x1 - 4`, which [`FixedAugSolver`]'s `x_ls =
979 /// [1, 3]` satisfies exactly.
980 ///
981 /// This was a constant `1.0` when gh#604 wrote these tests, and
982 /// a constant will not do since gh#605: the least-square step is
983 /// now taken only when it reduces the *true* nonlinear
984 /// violation, and against a constant `c` no step ever can, so
985 /// `least_square_init_primal` would be correctly declined and
986 /// the option untestable here. An `x`-dependent row also makes
987 /// the stub honest — `x_ls` is supposed to be the point that
988 /// solves the linearized constraints, and now it is one.
989 fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector) {
990 let v = x
991 .as_any()
992 .downcast_ref::<DenseVector>()
993 .expect("dense x")
994 .expanded_values();
995 c.set(v[0] + v[1] - 4.0);
996 }
997 fn eval_d(&mut self, _x: &dyn Vector, d: &mut dyn Vector) {
998 d.set(-5.0);
999 }
1000 fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1001 Rc::new(IdentityMatrix::new(N_X))
1002 }
1003 fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1004 Rc::new(IdentityMatrix::new(N_X))
1005 }
1006 fn eval_h(
1007 &mut self,
1008 _x: &dyn Vector,
1009 _obj_factor: Number,
1010 _y_c: &dyn Vector,
1011 _y_d: &dyn Vector,
1012 ) -> Rc<dyn SymMatrix> {
1013 let s = pounce_linalg::DenseSymMatrixSpace::new(N_X);
1014 Rc::new(pounce_linalg::DenseSymMatrix::new(s))
1015 }
1016 }
1017
1018 impl crate::ipopt_nlp::IpoptNlp for StubNlp {
1019 fn x_l(&self) -> &dyn Vector {
1020 &self.x_l
1021 }
1022 fn x_u(&self) -> &dyn Vector {
1023 &self.x_u
1024 }
1025 fn d_l(&self) -> &dyn Vector {
1026 &self.d_l
1027 }
1028 fn d_u(&self) -> &dyn Vector {
1029 &self.d_u
1030 }
1031 fn px_l(&self) -> Rc<dyn Matrix> {
1032 self.p2.clone()
1033 }
1034 fn px_u(&self) -> Rc<dyn Matrix> {
1035 self.p2.clone()
1036 }
1037 fn pd_l(&self) -> Rc<dyn Matrix> {
1038 self.p1.clone()
1039 }
1040 fn pd_u(&self) -> Rc<dyn Matrix> {
1041 self.p1.clone()
1042 }
1043 fn get_starting_x(&mut self, x: &mut dyn Vector) -> bool {
1044 let dense = x
1045 .as_any_mut()
1046 .downcast_mut::<DenseVector>()
1047 .expect("dense x");
1048 dense.set_values(&[0.0, 10.0]);
1049 true
1050 }
1051 }
1052
1053 /// Fails every solve, so the least-square primal step is a no-op.
1054 /// Nothing else in the tested paths touches the aug solver.
1055 struct FailingAugSolver;
1056 /// Returns a fixed `sol_x`, so `least_square_init_primal=yes` lands
1057 /// on a point the bound push alone could never produce.
1058 struct FixedAugSolver;
1059
1060 macro_rules! aug_solver_boilerplate {
1061 () => {
1062 fn provides_inertia(&self) -> bool {
1063 false
1064 }
1065 fn number_of_neg_evals(&self) -> Index {
1066 0
1067 }
1068 fn increase_quality(&mut self) -> bool {
1069 false
1070 }
1071 fn last_solve_status(&self) -> ESymSolverStatus {
1072 ESymSolverStatus::Success
1073 }
1074 };
1075 }
1076
1077 impl AugSystemSolver for FailingAugSolver {
1078 aug_solver_boilerplate!();
1079 fn solve(
1080 &mut self,
1081 _coeffs: &AugSysCoeffs<'_>,
1082 _rhs: &AugSysRhs<'_>,
1083 _sol: &mut AugSysSol<'_>,
1084 _check_neg_evals: bool,
1085 _num_neg_evals: Index,
1086 ) -> ESymSolverStatus {
1087 ESymSolverStatus::Singular
1088 }
1089 }
1090
1091 impl AugSystemSolver for FixedAugSolver {
1092 aug_solver_boilerplate!();
1093 fn solve(
1094 &mut self,
1095 _coeffs: &AugSysCoeffs<'_>,
1096 _rhs: &AugSysRhs<'_>,
1097 sol: &mut AugSysSol<'_>,
1098 _check_neg_evals: bool,
1099 _num_neg_evals: Index,
1100 ) -> ESymSolverStatus {
1101 // The initializer negates this, so `x_ls = [1, 3]`.
1102 sol.sol_x
1103 .as_any_mut()
1104 .downcast_mut::<DenseVector>()
1105 .expect("dense sol_x")
1106 .set_values(&[-1.0, -3.0]);
1107 sol.sol_s.set(0.0);
1108 sol.sol_c.set(0.0);
1109 sol.sol_d.set(0.0);
1110 ESymSolverStatus::Success
1111 }
1112 }
1113
1114 /// Hands back a fixed equality-multiplier estimate so the
1115 /// `constr_mult_init_max` cap has something to accept or discard.
1116 struct FixedEqMults(Number);
1117 impl EqMultCalculator for FixedEqMults {
1118 fn calculate_y_eq(
1119 &mut self,
1120 _data: &IpoptDataHandle,
1121 _cq: &IpoptCqHandle,
1122 _nlp: &Rc<RefCell<dyn IpoptNlp>>,
1123 _aug_solver: &mut dyn AugSystemSolver,
1124 y_c: &mut dyn Vector,
1125 y_d: &mut dyn Vector,
1126 ) -> bool {
1127 y_c.set(self.0);
1128 y_d.set(self.0);
1129 true
1130 }
1131 }
1132
1133 fn zeros(n: Index) -> Rc<DenseVector> {
1134 let mut v = DenseVectorSpace::new(n).make_new_dense();
1135 v.set(0.0);
1136 Rc::new(v)
1137 }
1138
1139 /// A data/cq pair over [`StubNlp`] with a correctly-shaped `curr`
1140 /// installed — the initializer reads the block dimensions off it.
1141 fn fixture() -> (IpoptDataHandle, IpoptCqHandle, Rc<RefCell<dyn IpoptNlp>>) {
1142 let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(StubNlp::new()));
1143 let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1144 let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1145 Rc::clone(&data),
1146 Rc::clone(&nlp),
1147 )));
1148 let template = IteratesVector::new(
1149 zeros(N_X),
1150 zeros(N_S),
1151 zeros(N_C),
1152 zeros(N_S),
1153 zeros(N_X),
1154 zeros(N_X),
1155 zeros(N_S),
1156 zeros(N_S),
1157 );
1158 data.borrow_mut().set_curr(template);
1159 (data, cq, nlp)
1160 }
1161
1162 /// Run the initializer and return the installed `curr`.
1163 fn run(init: &mut DefaultIterateInitializer, aug: &mut dyn AugSystemSolver) -> IteratesVector {
1164 let (data, cq, nlp) = fixture();
1165 assert!(
1166 init.set_initial_iterates(&data, &cq, &nlp, aug),
1167 "initializer should succeed"
1168 );
1169 data.borrow().curr.clone().expect("curr installed")
1170 }
1171
1172 /// `expanded_values` rather than `values`: a block the initializer
1173 /// filled with `set` stays in the homogeneous representation, which
1174 /// `values` refuses to hand out.
1175 fn values_of(v: &Rc<dyn Vector>) -> Vec<Number> {
1176 v.as_any()
1177 .downcast_ref::<DenseVector>()
1178 .expect("dense")
1179 .expanded_values()
1180 }
1181 fn x_of(iv: &IteratesVector) -> Vec<Number> {
1182 values_of(&iv.x)
1183 }
1184
1185 /// `bound_push` moves `x0` off its lower bound by
1186 /// `min(bound_push * max(|lo|, 1), bound_frac * span)`.
1187 #[test]
1188 fn bound_push_changes_the_initial_primal() {
1189 let mut aug = FailingAugSolver;
1190
1191 let mut d = DefaultIterateInitializer::new();
1192 let base = x_of(&run(&mut d, &mut aug));
1193 assert!((base[0] - 1e-2).abs() < 1e-15, "default: {base:?}");
1194
1195 let mut pushed = DefaultIterateInitializer {
1196 bound_push: 5e-2,
1197 ..DefaultIterateInitializer::new()
1198 };
1199 let moved = x_of(&run(&mut pushed, &mut aug));
1200 assert!(
1201 (moved[0] - 5e-2).abs() < 1e-15,
1202 "bound_push=5e-2: {moved:?}"
1203 );
1204 assert_ne!(base[0], moved[0]);
1205 }
1206
1207 /// `bound_frac` is the other arm of the same min, and it binds when
1208 /// the interval is narrow relative to `bound_push`.
1209 #[test]
1210 fn bound_frac_changes_the_initial_primal() {
1211 let mut aug = FailingAugSolver;
1212 let mut init = DefaultIterateInitializer {
1213 bound_frac: 5e-4,
1214 ..DefaultIterateInitializer::new()
1215 };
1216 // span = 10, so the frac arm gives 5e-3 < bound_push's 1e-2.
1217 let x = x_of(&run(&mut init, &mut aug));
1218 assert!((x[0] - 5e-3).abs() < 1e-15, "bound_frac=5e-4: {x:?}");
1219 }
1220
1221 /// The slack knobs do the same job for `s`, which starts on the
1222 /// lower inequality bound `-5`.
1223 #[test]
1224 fn slack_bound_push_and_frac_change_the_initial_slack() {
1225 let mut aug = FailingAugSolver;
1226
1227 let mut d = DefaultIterateInitializer::new();
1228 // p_l = min(1e-2 * max(|-5|, 1), 1e-2 * 10) = 5e-2.
1229 let base = values_of(&run(&mut d, &mut aug).s);
1230 assert!((base[0] - -4.95).abs() < 1e-14, "default: {base:?}");
1231
1232 let mut pushed = DefaultIterateInitializer {
1233 slack_bound_push: 1e-1,
1234 ..DefaultIterateInitializer::new()
1235 };
1236 // p_l = min(1e-1 * 5, 1e-2 * 10) = 1e-1 — the frac arm still binds.
1237 let s = values_of(&run(&mut pushed, &mut aug).s);
1238 assert!((s[0] - -4.9).abs() < 1e-14, "slack_bound_push=1e-1: {s:?}");
1239
1240 let mut fracced = DefaultIterateInitializer {
1241 slack_bound_frac: 1e-3,
1242 ..DefaultIterateInitializer::new()
1243 };
1244 // p_l = min(1e-2 * 5, 1e-3 * 10) = 1e-2.
1245 let s = values_of(&run(&mut fracced, &mut aug).s);
1246 assert!((s[0] - -4.99).abs() < 1e-14, "slack_bound_frac=1e-3: {s:?}");
1247 }
1248
1249 /// `bound_mult_init_val` is the value every bound multiplier takes.
1250 #[test]
1251 fn bound_mult_init_val_changes_the_bound_multipliers() {
1252 let mut aug = FailingAugSolver;
1253
1254 let base = run(&mut DefaultIterateInitializer::new(), &mut aug);
1255 assert_eq!(values_of(&base.z_l), vec![1.0, 1.0]);
1256 assert_eq!(values_of(&base.v_u), vec![1.0]);
1257
1258 let mut init = DefaultIterateInitializer {
1259 bound_mult_init_val: 7.5,
1260 ..DefaultIterateInitializer::new()
1261 };
1262 let iv = run(&mut init, &mut aug);
1263 assert_eq!(values_of(&iv.z_l), vec![7.5, 7.5]);
1264 assert_eq!(values_of(&iv.z_u), vec![7.5, 7.5]);
1265 assert_eq!(values_of(&iv.v_l), vec![7.5]);
1266 assert_eq!(values_of(&iv.v_u), vec![7.5]);
1267 }
1268
1269 /// `constr_mult_init_max` caps the least-square equality-multiplier
1270 /// estimate: above the cap upstream discards it and leaves zeros.
1271 #[test]
1272 fn constr_mult_init_max_gates_the_equality_multipliers() {
1273 let mut aug = FailingAugSolver;
1274
1275 let mut accepted = DefaultIterateInitializer {
1276 constr_mult_init_max: 1e3,
1277 ..DefaultIterateInitializer::with_eq_mult_calculator(Box::new(FixedEqMults(2.0)))
1278 };
1279 assert_eq!(values_of(&run(&mut accepted, &mut aug).y_c), vec![2.0]);
1280
1281 let mut capped = DefaultIterateInitializer {
1282 constr_mult_init_max: 1.0,
1283 ..DefaultIterateInitializer::with_eq_mult_calculator(Box::new(FixedEqMults(2.0)))
1284 };
1285 assert_eq!(
1286 values_of(&run(&mut capped, &mut aug).y_c),
1287 vec![0.0],
1288 "an estimate above the cap is discarded, not clamped"
1289 );
1290
1291 // 0 switches the least-square step off entirely.
1292 let mut off = DefaultIterateInitializer {
1293 constr_mult_init_max: 0.0,
1294 ..DefaultIterateInitializer::with_eq_mult_calculator(Box::new(FixedEqMults(2.0)))
1295 };
1296 assert_eq!(values_of(&run(&mut off, &mut aug).y_c), vec![0.0]);
1297 }
1298
1299 /// `least_square_init_primal=yes` replaces the user's `x0` with the
1300 /// solution of the linearized-constraint system.
1301 #[test]
1302 fn least_square_init_primal_replaces_the_starting_point() {
1303 let mut fixed = FixedAugSolver;
1304
1305 let mut off = DefaultIterateInitializer::new();
1306 let base = x_of(&run(&mut off, &mut fixed));
1307 assert!((base[0] - 1e-2).abs() < 1e-15, "user x0, pushed: {base:?}");
1308
1309 let mut on = DefaultIterateInitializer {
1310 least_square_init_primal: true,
1311 ..DefaultIterateInitializer::new()
1312 };
1313 let ls = x_of(&run(&mut on, &mut fixed));
1314 assert_eq!(
1315 ls,
1316 vec![1.0, 3.0],
1317 "the least-square point, already interior"
1318 );
1319 }
1320
1321 /// The one mode pounce implements runs; anything else fails rather
1322 /// than quietly running a third behaviour (gh#604). The refusal a
1323 /// caller actually sees is raised earlier, at the application layer.
1324 #[test]
1325 fn an_unsupported_bound_mult_init_method_fails_instead_of_falling_back() {
1326 let (data, cq, nlp) = fixture();
1327 let mut aug = FailingAugSolver;
1328 let mut init = DefaultIterateInitializer {
1329 bound_mult_init_method: "mu-based".into(),
1330 ..DefaultIterateInitializer::new()
1331 };
1332 assert!(
1333 !init.set_initial_iterates(&data, &cq, &nlp, &mut aug),
1334 "`mu-based` is not implemented and must not be served as `constant`"
1335 );
1336
1337 // Spelling is the only thing that varies — case does not.
1338 let (data, cq, nlp) = fixture();
1339 let mut cased = DefaultIterateInitializer {
1340 bound_mult_init_method: "CONSTANT".into(),
1341 ..DefaultIterateInitializer::new()
1342 };
1343 assert!(cased.set_initial_iterates(&data, &cq, &nlp, &mut aug));
1344 }
1345}