pounce_algorithm/kkt/pd_search_dir_calc.rs
1//! PD search-direction calculator — port of
2//! `Algorithm/IpPDSearchDirCalc.{hpp,cpp}`.
3//!
4//! Builds the right-hand side from the current iterate's KKT
5//! residuals (gradient of Lagrangian, constraint values, relaxed
6//! complementarities), optionally adds a Mehrotra corrector, then
7//! calls `PdFullSpaceSolver::solve` to produce the search direction
8//! `delta`.
9//!
10//! Two RHS modes:
11//! * standard: z-blocks are the relaxed complementarities
12//! `s_L · z_L − μ`, …
13//! * Mehrotra: z-blocks include the second-order term
14//! `(P_L^T Δx_aff) · Δz_aff_L + (s_L · z_L − μ)`.
15
16use crate::ipopt_cq::IpoptCqHandle;
17use crate::ipopt_data::IpoptDataHandle;
18use crate::ipopt_nlp::IpoptNlp;
19use crate::iterates_vector::{IteratesVector, IteratesVectorMut};
20use crate::kkt::pd_full_space_solver::PdFullSpaceSolver;
21use crate::kkt::search_dir_calc::SearchDirCalculator;
22use pounce_common::types::Number;
23use std::cell::{RefCell, RefMut};
24use std::rc::Rc;
25
26pub struct PdSearchDirCalc {
27 /// Owned via `Rc<RefCell<…>>` so external callers (e.g. the
28 /// post-converged sensitivity callback) can retain a cloned handle
29 /// past the IPM call. During the IPM loop refcount is 1 and every
30 /// internal call goes through `borrow_mut`; the runtime borrow
31 /// check costs are negligible relative to the linear solve.
32 pd_solver: Rc<RefCell<PdFullSpaceSolver>>,
33 /// Skip the residual check on the search direction. Mirrors
34 /// `fast_step_computation` (default false).
35 pub fast_step_computation: bool,
36 /// Mehrotra-style predictor-corrector step. Mirrors
37 /// `mehrotra_algorithm` (default false in v1.0; flipped on by
38 /// the adaptive-mu wiring in Phase 10).
39 pub mehrotra_algorithm: bool,
40}
41
42impl PdSearchDirCalc {
43 pub fn new(pd_solver: PdFullSpaceSolver) -> Self {
44 Self {
45 pd_solver: Rc::new(RefCell::new(pd_solver)),
46 fast_step_computation: false,
47 mehrotra_algorithm: false,
48 }
49 }
50
51 /// Clone the shared handle to the PD solver. Used by the
52 /// post-converged sensitivity callback to retain a factor handle
53 /// past the IPM call.
54 pub fn pd_solver_rc(&self) -> Rc<RefCell<PdFullSpaceSolver>> {
55 Rc::clone(&self.pd_solver)
56 }
57
58 /// Borrow the PD solver mutably. Caller is responsible for not
59 /// holding two mutable borrows at once (single-thread, single-
60 /// borrow access pattern — matches every existing call site).
61 pub fn pd_solver_mut(&self) -> RefMut<'_, PdFullSpaceSolver> {
62 self.pd_solver.borrow_mut()
63 }
64
65 /// Compute the search direction and write it back into
66 /// `data.delta`. Returns `false` if the underlying linear solve
67 /// fails. Mirrors `PDSearchDirCalculator::ComputeSearchDirection`.
68 pub fn compute_search_direction(
69 &mut self,
70 data: &IpoptDataHandle,
71 cq: &IpoptCqHandle,
72 nlp: &Rc<RefCell<dyn IpoptNlp>>,
73 ) -> bool {
74 let improve_solution = data.borrow().delta.is_some();
75
76 if improve_solution && self.fast_step_computation {
77 return true;
78 }
79
80 let curr = {
81 let d = data.borrow();
82 d.curr
83 .clone()
84 .unwrap_or_else(|| panic!("PdSearchDirCalc: IpoptData::curr is unset"))
85 };
86
87 // Build RHS.
88 let mut rhs = curr.make_new_zeroed();
89 {
90 let cq_ref = cq.borrow();
91 rhs.x.copy(&*cq_ref.curr_grad_lag_with_damping_x());
92 rhs.s.copy(&*cq_ref.curr_grad_lag_with_damping_s());
93 rhs.y_c.copy(&*cq_ref.curr_c());
94 rhs.y_d.copy(&*cq_ref.curr_d_minus_s());
95 }
96
97 let nbounds = {
98 let n = nlp.borrow();
99 n.x_l().dim() + n.x_u().dim() + n.d_l().dim() + n.d_u().dim()
100 };
101
102 // The Mehrotra corrector needs the affine (predictor) step, which
103 // the adaptive-μ oracle stores in `data.delta_aff`. Several
104 // legitimate paths leave it unset at this point even though
105 // `mehrotra_algorithm` is on: the affine solve can fail and fall
106 // back to the LOQO oracle (which computes no predictor step), the
107 // probing iterate-quality guard can return early without one, or we
108 // may be on an iteration before any affine step has been taken. In
109 // those cases fall back to the plain primal-dual z-blocks rather
110 // than panicking — the corrector is a second-order refinement, and
111 // the plain direction is a valid (if less aggressive) step. If the
112 // KKT system is genuinely unsolvable, `pd_solver.solve` below will
113 // report it by returning `false`, which the caller surfaces as a
114 // failed solve (`ErrorInStepComputation`) — a status callers can
115 // catch, unlike a process-killing panic (gh #231).
116 let delta_aff = if nbounds > 0 && self.mehrotra_algorithm {
117 data.borrow().delta_aff.clone()
118 } else {
119 None
120 };
121
122 if let Some(delta_aff) = delta_aff {
123 self.fill_mehrotra_z_blocks(&delta_aff, cq, nlp, &mut rhs);
124 } else {
125 if nbounds > 0 && self.mehrotra_algorithm {
126 tracing::debug!(target: "pounce::algorithm",
127 "PdSearchDirCalc: Mehrotra corrector requested but no \
128 affine step available; using the plain primal-dual \
129 direction this iteration.");
130 }
131 let cq_ref = cq.borrow();
132 rhs.z_l.copy(&*cq_ref.curr_relaxed_compl_x_l());
133 rhs.z_u.copy(&*cq_ref.curr_relaxed_compl_x_u());
134 rhs.v_l.copy(&*cq_ref.curr_relaxed_compl_s_l());
135 rhs.v_u.copy(&*cq_ref.curr_relaxed_compl_s_u());
136 }
137
138 let frozen_rhs = rhs.freeze();
139
140 // Allocate the search direction. If we are improving an
141 // existing one, seed it with `−delta` (per upstream).
142 let mut delta = frozen_rhs.make_new_zeroed();
143 if improve_solution {
144 let prev = {
145 let d = data.borrow();
146 let Some(p) = d.delta.clone() else {
147 unreachable!("PdSearchDirCalc: delta cleared between is_some() and clone()")
148 };
149 p
150 };
151 delta.add_one_vector(-1.0, &prev, 0.0);
152 }
153
154 let allow_inexact = self.fast_step_computation;
155 let ok = self.pd_solver.borrow_mut().solve(
156 data,
157 cq,
158 nlp,
159 -1.0,
160 0.0,
161 &frozen_rhs,
162 &mut delta,
163 allow_inexact,
164 improve_solution,
165 );
166
167 if ok {
168 data.borrow_mut().set_delta(delta.freeze());
169 }
170 ok
171 }
172
173 /// Affine (predictor) step — port of upstream's
174 /// `IpAdaptiveMuUpdate::ComputeMuMehrotra` predictor solve. Builds
175 /// the same RHS as [`Self::compute_search_direction`] except the
176 /// z-blocks use the *unrelaxed* complementarity `s · z`
177 /// (μ-target = 0) so the resulting step targets the affine-scaling
178 /// system. The solution is stored in `data.delta_aff` for
179 /// consumption by the Probing / Quality-Function oracles.
180 ///
181 /// Returns `false` if the linear solve fails.
182 pub fn compute_affine_step(
183 &mut self,
184 data: &IpoptDataHandle,
185 cq: &IpoptCqHandle,
186 nlp: &Rc<RefCell<dyn IpoptNlp>>,
187 ) -> bool {
188 let curr = {
189 let d = data.borrow();
190 d.curr
191 .clone()
192 .unwrap_or_else(|| panic!("PdSearchDirCalc: IpoptData::curr is unset"))
193 };
194
195 let mut rhs = curr.make_new_zeroed();
196 {
197 let cq_ref = cq.borrow();
198 // Upstream `IpQualityFunctionMuOracle.cpp:193-200` uses the
199 // *plain* `curr_grad_lag_{x,s}` here, NOT the damped variant.
200 // The `μ·κ_d·(P_L − P_U)` damping enters the main-step RHS
201 // only — for the affine (predictor) RHS upstream wants the
202 // gradient at μ=0.
203 rhs.x.copy(&*cq_ref.curr_grad_lag_x());
204 rhs.s.copy(&*cq_ref.curr_grad_lag_s());
205 rhs.y_c.copy(&*cq_ref.curr_c());
206 rhs.y_d.copy(&*cq_ref.curr_d_minus_s());
207 // Affine RHS: complementarity blocks use `s·z` (μ=0),
208 // not `s·z − μ`.
209 rhs.z_l.copy(&*cq_ref.curr_compl_x_l());
210 rhs.z_u.copy(&*cq_ref.curr_compl_x_u());
211 rhs.v_l.copy(&*cq_ref.curr_compl_s_l());
212 rhs.v_u.copy(&*cq_ref.curr_compl_s_u());
213 }
214
215 let frozen_rhs = rhs.freeze();
216 let mut delta_aff = frozen_rhs.make_new_zeroed();
217
218 // Upstream `IpQualityFunctionMuOracle.cpp:208` and
219 // `IpProbingMuOracle.cpp:79` both pass `allow_inexact = true`
220 // on the affine (predictor) solve: "we allow a somewhat
221 // inexact solution here ... iterative refinement will be done
222 // after mu is known". Skipping IR on the predictor saves
223 // ~5-10x per-iter linsol work on Mehrotra runs.
224 let ok = self.pd_solver.borrow_mut().solve(
225 data,
226 cq,
227 nlp,
228 -1.0,
229 0.0,
230 &frozen_rhs,
231 &mut delta_aff,
232 true,
233 false,
234 );
235
236 if ok {
237 data.borrow_mut().set_delta_aff(delta_aff.freeze());
238 }
239 ok
240 }
241
242 /// Pure centering step — port of upstream
243 /// `IpQualityFunctionMuOracle.cpp::CalculateMu` lines 218-247. RHS
244 /// is `(0, 0, 0, 0, μ̄·1, μ̄·1, μ̄·1, μ̄·1)` with μ̄ = `curr_avrg_compl`.
245 /// Solution stored on `data.delta_cen` for the quality-function
246 /// oracle's σ-bracket search.
247 ///
248 /// Returns `false` if the linear solve fails.
249 pub fn compute_centering_step(
250 &mut self,
251 data: &IpoptDataHandle,
252 cq: &IpoptCqHandle,
253 nlp: &Rc<RefCell<dyn IpoptNlp>>,
254 ) -> bool {
255 let curr = {
256 let d = data.borrow();
257 d.curr
258 .clone()
259 .unwrap_or_else(|| panic!("PdSearchDirCalc: IpoptData::curr is unset"))
260 };
261 let avrg_compl = cq.borrow().curr_avrg_compl();
262
263 let mut rhs = curr.make_new_zeroed();
264 // x/s blocks: -avrg_compl · grad_kappa_times_damping_{x,s}, per
265 // upstream IpQualityFunctionMuOracle.cpp:229-230. With kappa_d=0
266 // (the default) these are zero, but kappa_d=1e-5 (default) makes
267 // them nonzero on damped components and the centering direction
268 // depends on them.
269 {
270 let cq_ref = cq.borrow();
271 rhs.x
272 .add_one_vector(-avrg_compl, &*cq_ref.grad_kappa_times_damping_x(), 0.0);
273 rhs.s
274 .add_one_vector(-avrg_compl, &*cq_ref.grad_kappa_times_damping_s(), 0.0);
275 }
276 rhs.y_c.set(0.0);
277 rhs.y_d.set(0.0);
278 rhs.z_l.set(avrg_compl);
279 rhs.z_u.set(avrg_compl);
280 rhs.v_l.set(avrg_compl);
281 rhs.v_u.set(avrg_compl);
282
283 let frozen_rhs = rhs.freeze();
284 let mut delta_cen = frozen_rhs.make_new_zeroed();
285
286 // Match upstream `IpQualityFunctionMuOracle.cpp:243`: IR is
287 // deferred until mu is known, so we allow a somewhat inexact
288 // centering solve here.
289 let ok = self.pd_solver.borrow_mut().solve(
290 data,
291 cq,
292 nlp,
293 1.0,
294 0.0,
295 &frozen_rhs,
296 &mut delta_cen,
297 true,
298 false,
299 );
300
301 if ok {
302 data.borrow_mut().set_delta_cen(delta_cen.freeze());
303 }
304 ok
305 }
306
307 /// Solve the second-order-correction (SOC) linear system used by
308 /// the filter line search to recover full-step acceptability when
309 /// the Newton step grows the constraint violation. Mirrors the RHS
310 /// assembly + `pd_solver_->Solve(-1.0, 0.0, ...)` block in upstream
311 /// `IpFilterLSAcceptor.cpp:577-608`.
312 ///
313 /// The caller supplies the SOC right-hand sides for the equality and
314 /// inequality blocks (`c_soc`, `dms_soc`); this method assembles the
315 /// remaining six blocks using the current iterate's KKT residuals
316 /// and returns the resulting `delta_soc`. `soc_method = 0` matches
317 /// upstream's default (gradient blocks unscaled); `soc_method = 1`
318 /// scales the gradient blocks by `alpha_primal_soc` to reuse a
319 /// previously-tried correction.
320 pub fn compute_soc_step(
321 &mut self,
322 data: &IpoptDataHandle,
323 cq: &IpoptCqHandle,
324 nlp: &Rc<RefCell<dyn IpoptNlp>>,
325 c_soc: &dyn pounce_linalg::Vector,
326 dms_soc: &dyn pounce_linalg::Vector,
327 alpha_primal_soc: Number,
328 soc_method: i32,
329 ) -> Option<IteratesVector> {
330 let curr = {
331 let d = data.borrow();
332 d.curr
333 .clone()
334 .unwrap_or_else(|| panic!("PdSearchDirCalc::compute_soc_step: curr is unset"))
335 };
336 let mut rhs = curr.make_new_zeroed();
337 {
338 let cq_ref = cq.borrow();
339 rhs.x.copy(&*cq_ref.curr_grad_lag_with_damping_x());
340 rhs.s.copy(&*cq_ref.curr_grad_lag_with_damping_s());
341 if soc_method == 1 {
342 rhs.x.scal(alpha_primal_soc);
343 rhs.s.scal(alpha_primal_soc);
344 }
345 rhs.y_c.copy(c_soc);
346 rhs.y_d.copy(dms_soc);
347 rhs.z_l.copy(&*cq_ref.curr_relaxed_compl_x_l());
348 rhs.z_u.copy(&*cq_ref.curr_relaxed_compl_x_u());
349 rhs.v_l.copy(&*cq_ref.curr_relaxed_compl_s_l());
350 rhs.v_u.copy(&*cq_ref.curr_relaxed_compl_s_u());
351 }
352 let frozen_rhs = rhs.freeze();
353 let mut delta_soc = frozen_rhs.make_new_zeroed();
354 let ok = self.pd_solver.borrow_mut().solve(
355 data,
356 cq,
357 nlp,
358 -1.0,
359 0.0,
360 &frozen_rhs,
361 &mut delta_soc,
362 false,
363 false,
364 );
365 if ok { Some(delta_soc.freeze()) } else { None }
366 }
367
368 /// Mehrotra z-block:
369 /// tmp_zL = P_L^T · Δx_aff; tmp_zL ⊙= Δz_aff_L; tmp_zL += relaxed_compl_x_L
370 /// Symmetric for the U / s blocks.
371 fn fill_mehrotra_z_blocks(
372 &self,
373 delta_aff: &IteratesVector,
374 cq: &IpoptCqHandle,
375 nlp: &Rc<RefCell<dyn IpoptNlp>>,
376 rhs: &mut IteratesVectorMut,
377 ) {
378 let n = nlp.borrow();
379 let cq_ref = cq.borrow();
380
381 // z_L
382 n.px_l()
383 .trans_mult_vector(1.0, &*delta_aff.x, 0.0, &mut *rhs.z_l);
384 rhs.z_l.element_wise_multiply(&*delta_aff.z_l);
385 rhs.z_l.axpy(1.0, &*cq_ref.curr_relaxed_compl_x_l());
386
387 // z_U
388 n.px_u()
389 .trans_mult_vector(-1.0, &*delta_aff.x, 0.0, &mut *rhs.z_u);
390 rhs.z_u.element_wise_multiply(&*delta_aff.z_u);
391 rhs.z_u.axpy(1.0, &*cq_ref.curr_relaxed_compl_x_u());
392
393 // v_L
394 n.pd_l()
395 .trans_mult_vector(1.0, &*delta_aff.s, 0.0, &mut *rhs.v_l);
396 rhs.v_l.element_wise_multiply(&*delta_aff.v_l);
397 rhs.v_l.axpy(1.0, &*cq_ref.curr_relaxed_compl_s_l());
398
399 // v_U
400 n.pd_u()
401 .trans_mult_vector(-1.0, &*delta_aff.s, 0.0, &mut *rhs.v_u);
402 rhs.v_u.element_wise_multiply(&*delta_aff.v_u);
403 rhs.v_u.axpy(1.0, &*cq_ref.curr_relaxed_compl_s_u());
404 }
405}
406
407impl SearchDirCalculator for PdSearchDirCalc {}
408
409// --- per-element helpers retained from the Phase-6 stub for
410// downstream callers (CG-penalty path, restoration RHS unit tests).
411// Not used by `compute_search_direction` itself.
412
413pub fn mehrotra_corrector_lower(
414 delta_aff_x_lo: Number,
415 delta_aff_z: Number,
416 relaxed_compl: Number,
417) -> Number {
418 delta_aff_x_lo * delta_aff_z + relaxed_compl
419}
420
421pub fn mehrotra_corrector_upper(
422 delta_aff_x_up: Number,
423 delta_aff_z: Number,
424 relaxed_compl: Number,
425) -> Number {
426 -delta_aff_x_up * delta_aff_z + relaxed_compl
427}
428
429pub fn relaxed_complementarity(x: Number, z: Number, mu: Number) -> Number {
430 x * z - mu
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn relaxed_compl_at_central_path_is_zero() {
439 assert_eq!(relaxed_complementarity(2.0, 0.5, 1.0), 0.0);
440 }
441
442 #[test]
443 fn mehrotra_lower_combines_linearly() {
444 assert_eq!(mehrotra_corrector_lower(1.0, 2.0, 0.5), 2.5);
445 }
446
447 #[test]
448 fn mehrotra_upper_negates_dx() {
449 assert_eq!(mehrotra_corrector_upper(1.0, 2.0, 0.5), -1.5);
450 }
451}