Skip to main content

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}