Skip to main content

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}