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` 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}