pounce_algorithm/eq_mult/least_square.rs
1//! Least-squares multiplier estimate — port of
2//! `Algorithm/IpLeastSquareMults.{hpp,cpp}`. Solves the W=0
3//! augmented system to get an initial `y_c`/`y_d`.
4//!
5//! The system, with `delta_x = delta_s = 1.0` and all other
6//! perturbations / weights zero (matching upstream `IpLeastSquareMults.cpp:60`):
7//!
8//! ```text
9//! [ I 0 J_c^T J_d^T ] [dx ] [ −∇f + Pₗ z_L − Pᵤ z_U ]
10//! [ 0 I 0 −I ] [ds ] = [ Pₗ v_L − Pᵤ v_U ]
11//! [ J_c 0 0 0 ] [dyc] [ 0 ]
12//! [ J_d −I 0 0 ] [dyd] [ 0 ]
13//! ```
14//!
15//! Sign convention from `IpLeastSquareMults.cpp:54-61`. `dyc`, `dyd`
16//! are the least-squares estimates we keep as `y_c`, `y_d`; `dx`,
17//! `ds` are discarded.
18
19use crate::eq_mult::r#trait::EqMultCalculator;
20use crate::ipopt_cq::IpoptCqHandle;
21use crate::ipopt_data::IpoptDataHandle;
22use crate::ipopt_nlp::IpoptNlp;
23use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
24use pounce_linalg::Vector;
25use pounce_linsol::ESymSolverStatus;
26use std::cell::RefCell;
27use std::rc::Rc;
28
29pub struct LeastSquareMults;
30
31impl LeastSquareMults {
32 pub fn new() -> Self {
33 Self
34 }
35}
36
37impl Default for LeastSquareMults {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl EqMultCalculator for LeastSquareMults {
44 fn calculate_y_eq(
45 &mut self,
46 data: &IpoptDataHandle,
47 cq: &IpoptCqHandle,
48 nlp: &Rc<RefCell<dyn IpoptNlp>>,
49 aug_solver: &mut dyn AugSystemSolver,
50 y_c: &mut dyn Vector,
51 y_d: &mut dyn Vector,
52 ) -> bool {
53 let curr = match data.borrow().curr.clone() {
54 Some(c) => c,
55 None => return false,
56 };
57
58 // Pull NLP-evaluated quantities first so the `nlp.borrow_mut()`
59 // inside CQ's eval helpers can complete before we take the
60 // shared `nlp.borrow()` for the bound-selection matrices.
61 let cq_ref = cq.borrow();
62 let grad_f = cq_ref.curr_grad_f();
63 let j_c = cq_ref.curr_jac_c();
64 let j_d = cq_ref.curr_jac_d();
65 drop(cq_ref);
66
67 let nlp_ref = nlp.borrow();
68 // Upstream `IpLeastSquareMults.cpp:80` passes a `zeroW` SymMatrix
69 // (same sparsity as the real Hessian) with `W_factor=0.0`. This
70 // ensures `StdAugSystemSolver` pins its triplet structure with
71 // the W slots present, so subsequent calls (with the actual
72 // Hessian) write into those slots rather than skipping them.
73 //
74 // Structure only: upstream takes it from `IpNLP().uninitialized_h()`
75 // (`IpLeastSquareMults.cpp:38`), not from an evaluation. Using
76 // `curr_exact_hessian()` here — an unmemoized `eval_h` — invoked
77 // the user's Hessian callback once per `calculate_y_eq` even under
78 // `hessian_approximation = limited-memory`, where the whole point
79 // is that the user has declared they are not supplying one
80 // (gh#698). The values are never read: `w_factor` is 0.0.
81 let zero_w = nlp_ref.uninitialized_h();
82
83 // rhs_x = −∇f + Pₗ z_L − Pᵤ z_U (mirrors
84 // `IpLeastSquareMults.cpp:54-57` exactly).
85 let mut rhs_x = grad_f.make_new();
86 rhs_x.copy(&*grad_f);
87 nlp_ref
88 .px_l()
89 .mult_vector(1.0, &*curr.z_l, -1.0, &mut *rhs_x);
90 nlp_ref
91 .px_u()
92 .mult_vector(-1.0, &*curr.z_u, 1.0, &mut *rhs_x);
93
94 // rhs_s = Pₗ v_L − Pᵤ v_U (zero-init then mult; mirrors
95 // `IpLeastSquareMults.cpp:60-61`).
96 let mut rhs_s = curr.s.make_new();
97 nlp_ref
98 .pd_l()
99 .mult_vector(1.0, &*curr.v_l, 0.0, &mut *rhs_s);
100 nlp_ref
101 .pd_u()
102 .mult_vector(-1.0, &*curr.v_u, 1.0, &mut *rhs_s);
103
104 // rhs_c = 0, rhs_d = 0.
105 let mut rhs_c = curr.y_c.make_new();
106 rhs_c.set(0.0);
107 let mut rhs_d = curr.y_d.make_new();
108 rhs_d.set(0.0);
109
110 // sol_x, sol_s scratch (discarded after solve).
111 let mut sol_x = rhs_x.make_new();
112 let mut sol_s = rhs_s.make_new();
113
114 // δ_c = δ_d = 0, matching upstream `IpLeastSquareMults.cpp:80-81`,
115 // with a perturbed retry only if the unperturbed solve fails (#688).
116 //
117 // Eliminating `w` from this W=0 system gives
118 //
119 // y = −(J Jᵀ + δ I)⁻¹ J · rhs_x
120 //
121 // so δ=0 returns the least-squares multiplier this calculator is
122 // named for, and δ>0 returns a Tikhonov-regularized one, damped
123 // by `O(δ / σ_min(J)²)` along the weakest singular directions.
124 //
125 // Review item M3 introduced δ=1e-8 here — the site previously
126 // matched upstream — to mirror the dual initializer's workaround
127 // for pounce-feral mis-reporting the inertia of a
128 // structurally-zero (3,3)/(4,4) block (0 negatives on
129 // nuffield2_trap against a true n_c+n_d, raising WrongInertia).
130 // A spurious failure makes this return false and the caller
131 // leaves y_c=y_d=0. M3 argued the perturbation was numerically
132 // inert because the suite stayed green — true, and true only
133 // while `σ_min(J)² ≫ δ`, which holds for every problem in this
134 // repo. That is a statement about the covered problems, not a
135 // scale-free property.
136 //
137 // Two things retired it. gh#540 and gh#592 fixed feral's inertia
138 // reporting at its source — `inertia_trust_floor` reports
139 // `Singular` rather than `WrongInertia` when the count is
140 // contradicted by a working-precision pivot, so `δ_c` is reached
141 // for where it actually repairs a rank-deficient constraint
142 // block — which is the defect M3 was compensating for from a
143 // distance. And #688 measured what the compensation costs on a
144 // problem that leaves the inert regime: on a ~59,000-variable
145 // collocation NLP the damping is `O(1)`, and because `recalc_y`
146 // recomputes `y` the same biased way every iteration the error is
147 // a fixed point of the estimator rather than a transient. It
148 // lands directly in `inf_du`. `recalc_y=yes` stalled at 1.55e-01,
149 // *worse* than `recalc_y=no` at 1.73e-02; at δ=0 the model
150 // converges, on both MA57 and FERAL.
151 //
152 // The retry keeps M3's protection without paying for it: the
153 // common case is now bit-identical to upstream, and a solve that
154 // genuinely fails still gets the perturbation before the caller
155 // falls back to zero. `nuffield2_trap` is not in this repo, so
156 // the retry cannot be regression-tested here — the same reason
157 // M3 shipped without a fail-first test, and why the guard is kept
158 // rather than dropped outright.
159 //
160 // #688 could only reach `recalc_y`, so for one release the three
161 // *initializer* sites still took δ=1e-8 unconditionally, behind
162 // an `unregularized: bool`. gh#693 retired that flag: the same
163 // argument applies to a starting guess that a capped `y0` then
164 // carries into iteration 0, and no caller was left asking for the
165 // damped estimate. The retry below is now the only behaviour.
166 let mut status = ESymSolverStatus::Success;
167 let deltas: &[pounce_common::types::Number] = &[0.0, 1e-8];
168 for &delta in deltas {
169 let coeffs = AugSysCoeffs {
170 w: Some(&*zero_w),
171 w_factor: 0.0,
172 d_x: None,
173 delta_x: 1.0,
174 d_s: None,
175 delta_s: 1.0,
176 j_c: &*j_c,
177 d_c: None,
178 delta_c: delta,
179 j_d: &*j_d,
180 d_d: None,
181 delta_d: delta,
182 };
183 let aug_rhs = AugSysRhs {
184 rhs_x: &*rhs_x,
185 rhs_s: &*rhs_s,
186 rhs_c: &*rhs_c,
187 rhs_d: &*rhs_d,
188 };
189 let mut sol = AugSysSol {
190 sol_x: &mut *sol_x,
191 sol_s: &mut *sol_s,
192 sol_c: y_c,
193 sol_d: y_d,
194 };
195
196 let num_eq = aug_rhs.rhs_c.dim() + aug_rhs.rhs_d.dim();
197 let check_neg = aug_solver.provides_inertia();
198 status = aug_solver.solve(&coeffs, &aug_rhs, &mut sol, check_neg, num_eq);
199 if matches!(status, ESymSolverStatus::Success) {
200 return true;
201 }
202 }
203 matches!(status, ESymSolverStatus::Success)
204 }
205}