Skip to main content

pounce_algorithm/sqp/
sqp_alg.rs

1//! `SqpAlgorithm` — active-set SQP outer loop. Consumes an
2//! `SqpProblemSpec` for evaluation; delegates the QP subproblem
3//! solve to `pounce_qp::ParametricActiveSetSolver`.
4//!
5//! Outer loop (Nocedal-Wright §18 standard SQP):
6//! 1. Evaluate `f, ∇f, c, ∇c, ∇²L` at `x_k`.
7//! 2. Build the QP via `SqpQpData::build`.
8//! 3. Solve the QP via `pounce-qp` (warm-started by the previous
9//!    `WorkingSet` when available).
10//! 4. KKT-error check on `x_k` (before stepping) — if all
11//!    component tolerances are met, declare optimal.
12//! 5. Globalization step acceptance via either the Fletcher-
13//!    Leyffer 2002 filter (`SqpGlobalization::Filter`, default)
14//!    or the Han-Powell l1-merit (`SqpGlobalization::L1Elastic`),
15//!    both backtracking on α.
16//! 6. Take `α·p`; promote `(x_k + α p, λ_g, λ_x)` to the next
17//!    iterate and carry the QP's `WorkingSet` for the next solve.
18
19use crate::sqp::bfgs::DampedBfgs;
20use crate::sqp::filter::{filter_line_search, SqpFilter};
21use crate::sqp::iterates::SqpIterates;
22use crate::sqp::lbfgs::LBfgs;
23use crate::sqp::line_search::l1_merit_line_search;
24use crate::sqp::options::{SqpGlobalization, SqpHessianSource, SqpOptions};
25use crate::sqp::problem::SqpProblemSpec;
26use crate::sqp::qp_assembly::{SqpQpData, Triplet};
27use crate::sqp::result::{SqpError, SqpResult, SqpStatus};
28use pounce_common::types::{Number, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
29use pounce_qp::{HessianInertia, ParametricActiveSetSolver, QpOptions, QpSolver, QpStatus};
30
31/// SQP-side algorithm driver.
32pub struct SqpAlgorithm {
33    qp_solver: ParametricActiveSetSolver,
34    qp_opts: QpOptions,
35    opts: SqpOptions,
36    iterates: Option<SqpIterates>,
37    /// Filter for Fletcher-Leyffer globalization; reset at the
38    /// top of each `optimize` call. Unused when
39    /// `opts.globalization = L1Elastic`.
40    filter: SqpFilter,
41}
42
43impl SqpAlgorithm {
44    pub fn new(qp_solver: ParametricActiveSetSolver, opts: SqpOptions) -> Self {
45        Self {
46            qp_solver,
47            qp_opts: QpOptions::default(),
48            opts,
49            iterates: None,
50            filter: SqpFilter::new(),
51        }
52    }
53
54    /// Override the per-call QP-solver options. Defaults are the
55    /// `pounce_qp::QpOptions::default()` (which include the
56    /// `use_schur_updates = false` and `anti_cycling = Expand`
57    /// from Phase 5a.2). Callers can pin tighter tolerances or
58    /// flip `use_schur_updates = true` for warm-started workloads.
59    pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
60        self.qp_opts = qp_opts;
61        self
62    }
63
64    pub fn options(&self) -> &SqpOptions {
65        &self.opts
66    }
67
68    pub fn iterates(&self) -> Option<&SqpIterates> {
69        self.iterates.as_ref()
70    }
71
72    /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
73    /// the iterate from `nlp.x_init()` and an empty working set.
74    pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
75        self.optimize_with_warm_start(nlp, None)
76    }
77
78    /// Warm-start variant. `warm = Some(prev)` seeds the iterate
79    /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
80    /// NLP's cold defaults. Dimensions are validated against the
81    /// problem; any mismatch is fatal. The QP solver consumes
82    /// `warm.working` (when present) via `solve_with_working_set`.
83    ///
84    /// `warm = None` is equivalent to [`Self::optimize`].
85    ///
86    /// Implements the §6 design-note warm-start contract: the
87    /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
88    /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
89    /// payload — each `optimize` call rebuilds its own Hessian
90    /// approximation from scratch.
91    pub fn optimize_with_warm_start<N: SqpProblemSpec>(
92        &mut self,
93        nlp: &mut N,
94        warm: Option<SqpIterates>,
95    ) -> Result<SqpResult, SqpError> {
96        let n = nlp.n();
97        let m = nlp.m();
98        let (xl, xu) = nlp.variable_bounds();
99        let (bl_c, bu_c) = nlp.constraint_bounds();
100        if xl.len() != n || xu.len() != n {
101            return Err(SqpError::DimensionMismatch(format!(
102                "variable_bounds length must be n = {n}"
103            )));
104        }
105        if bl_c.len() != m || bu_c.len() != m {
106            return Err(SqpError::DimensionMismatch(format!(
107                "constraint_bounds length must be m = {m}"
108            )));
109        }
110
111        let mut iter = match warm {
112            Some(w) => {
113                if w.x.len() != n {
114                    return Err(SqpError::DimensionMismatch(format!(
115                        "warm.x length {} must equal n = {n}",
116                        w.x.len()
117                    )));
118                }
119                if w.lambda_g.len() != m {
120                    return Err(SqpError::DimensionMismatch(format!(
121                        "warm.lambda_g length {} must equal m = {m}",
122                        w.lambda_g.len()
123                    )));
124                }
125                if w.lambda_x.len() != n {
126                    return Err(SqpError::DimensionMismatch(format!(
127                        "warm.lambda_x length {} must equal n = {n}",
128                        w.lambda_x.len()
129                    )));
130                }
131                if let Some(ws) = w.working.as_ref() {
132                    ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
133                }
134                w
135            }
136            None => {
137                let mut cold = SqpIterates::cold(n, m);
138                let x_init = nlp.x_init();
139                if x_init.len() != n {
140                    return Err(SqpError::DimensionMismatch(format!(
141                        "x_init length must be n = {n}"
142                    )));
143                }
144                cold.x = x_init;
145                cold
146            }
147        };
148
149        let mut n_qp_solves: u32 = 0;
150        let mut final_stationarity = 0.0;
151        let mut final_constr_viol = 0.0;
152        // l1-merit penalty parameter ν, adapted across iterations
153        // by `l1_merit_line_search`. Initialized from
154        // `SqpOptions::l1_penalty`.
155        let mut nu = self.opts.l1_penalty;
156        // Reset filter state at the top of each optimize call.
157        self.filter = SqpFilter::new();
158        // Cache the most recent f(x) and c(x) so we don't
159        // re-evaluate them after a successful line search (the
160        // LS already computed them at the new iterate).
161        let mut f_cached: Option<Number> = None;
162        let mut c_cached: Option<Vec<Number>> = None;
163
164        // Damped-BFGS state, allocated only if needed. The
165        // matrix is updated at the END of each iteration (after
166        // we have x_new and the next ∇L), then queried at the
167        // TOP of the next iteration to populate the QP Hessian.
168        let mut bfgs: Option<DampedBfgs> =
169            if matches!(self.opts.hessian, SqpHessianSource::DampedBfgs) {
170                Some(DampedBfgs::new(n))
171            } else {
172                None
173            };
174        let mut lbfgs: Option<LBfgs> = if matches!(self.opts.hessian, SqpHessianSource::Lbfgs) {
175            Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
176        } else {
177            None
178        };
179
180        for outer in 0..self.opts.max_iter {
181            let grad_f = nlp.eval_grad_f(&iter.x);
182            let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
183            let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
184            let jac_c = nlp.eval_jac_c(&iter.x);
185            let hess_lag = match self.opts.hessian {
186                SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
187                SqpHessianSource::DampedBfgs => {
188                    let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
189                    // Update on the *current* (x, ∇L). The
190                    // very first iteration's update is a no-op
191                    // (no previous pair); the matrix stays I.
192                    let grad_lag = compute_grad_lag(&grad_f, &jac_c, &iter.lambda_g, n);
193                    bfgs.update(&iter.x, &grad_lag);
194                    bfgs.as_triplet()
195                }
196                SqpHessianSource::Lbfgs => {
197                    let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
198                    let grad_lag = compute_grad_lag(&grad_f, &jac_c, &iter.lambda_g, n);
199                    lb.update(&iter.x, &grad_lag);
200                    lb.as_triplet()
201                }
202            };
203
204            // KKT check uses the current iterate's evaluations.
205            let kkt = check_kkt(
206                n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
207            );
208            final_stationarity = kkt.stationarity;
209            final_constr_viol = kkt.constr_viol;
210
211            #[cfg(test)]
212            if self.opts.print_level >= 1 {
213                tracing::debug!(target: "pounce::sqp",
214                    "[sqp k={outer:3}] x={:?} f={:.4e} ‖c‖={:.2e} stat={:.2e} ν={:.2e}",
215                    iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
216                    f_curr,
217                    kkt.constr_viol,
218                    kkt.stationarity,
219                    nu,
220                );
221            }
222
223            if kkt.stationarity <= self.opts.dual_inf_tol
224                && kkt.constr_viol <= self.opts.constr_viol_tol
225            {
226                self.iterates = Some(iter.clone());
227                return Ok(SqpResult {
228                    x: iter.x,
229                    lambda_g: iter.lambda_g,
230                    lambda_x: iter.lambda_x,
231                    obj: f_curr,
232                    status: SqpStatus::Optimal,
233                    n_iter: outer,
234                    n_qp_solves,
235                    final_stationarity,
236                    final_constr_viol,
237                    working_set: iter.working,
238                });
239            }
240
241            let qp_data = SqpQpData::build(
242                &iter.x,
243                &grad_f,
244                &c_vals,
245                &bl_c,
246                &bu_c,
247                &xl,
248                &xu,
249                jac_c,
250                hess_lag,
251                self.hessian_inertia(),
252            );
253            let qp = qp_data.as_qp();
254
255            // Warm-start from the previous QP's working set when
256            // available. Pounce-qp's `solve_with_working_set`
257            // internally computes a feasible primal compatible
258            // with the supplied set (it satisfies every active
259            // row exactly) — necessary because each SQP
260            // linearization shifts the QP's constraint RHS by
261            // `-c(x_k)`, so the previous QP's *primal* doesn't
262            // carry over even when the active set does.
263            let sol = if let Some(prev_w) = iter.working.as_ref() {
264                self.qp_solver
265                    .solve_with_working_set(&qp, prev_w, &self.qp_opts)?
266            } else {
267                self.qp_solver.solve(&qp, None, &self.qp_opts)?
268            };
269            n_qp_solves += 1;
270
271            match sol.status {
272                QpStatus::Optimal => {}
273                QpStatus::Infeasible => {
274                    let obj = nlp.eval_f(&iter.x);
275                    self.iterates = Some(iter.clone());
276                    return Ok(SqpResult {
277                        x: iter.x,
278                        lambda_g: iter.lambda_g,
279                        lambda_x: iter.lambda_x,
280                        obj,
281                        status: SqpStatus::InfeasibleSubproblem,
282                        n_iter: outer,
283                        n_qp_solves,
284                        final_stationarity,
285                        final_constr_viol,
286                        working_set: iter.working,
287                    });
288                }
289                other => {
290                    return Err(SqpError::QpFailure(
291                        pounce_qp::QpError::LinearSolverFailure(format!(
292                            "QP subproblem returned status {other}"
293                        )),
294                    ));
295                }
296            }
297
298            #[cfg(test)]
299            if self.opts.print_level >= 1 {
300                let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
301                tracing::debug!(target: "pounce::sqp",
302                    "         qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
303                    p_inf,
304                    sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
305                );
306            }
307            // Globalization: l1-merit backtracking (Han-Powell)
308            // or filter (Fletcher-Leyffer 2002). The two share
309            // the same backtracking shell + acceptance API; the
310            // filter keeps state across iterations on
311            // `self.filter`.
312            let ls = match self.opts.globalization {
313                SqpGlobalization::L1Elastic => l1_merit_line_search(
314                    nlp,
315                    &iter.x,
316                    &sol.x,
317                    &sol.lambda_g,
318                    &grad_f,
319                    f_curr,
320                    &c_vals,
321                    &bl_c,
322                    &bu_c,
323                    &xl,
324                    &xu,
325                    nu,
326                    &self.opts,
327                ),
328                SqpGlobalization::Filter => filter_line_search(
329                    nlp,
330                    &mut self.filter,
331                    &iter.x,
332                    &sol.x,
333                    f_curr,
334                    &c_vals,
335                    &bl_c,
336                    &bu_c,
337                    &xl,
338                    &xu,
339                    nu,
340                    &self.opts,
341                ),
342            };
343            #[cfg(test)]
344            if self.opts.print_level >= 1 {
345                tracing::debug!(target: "pounce::sqp",
346                    "         ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
347                    ls.alpha, ls.nu, ls.success, ls.f_new
348                );
349            }
350            if !ls.success {
351                self.iterates = Some(iter.clone());
352                return Ok(SqpResult {
353                    x: iter.x,
354                    lambda_g: iter.lambda_g,
355                    lambda_x: iter.lambda_x,
356                    obj: f_curr,
357                    status: SqpStatus::LineSearchFailed,
358                    n_iter: outer,
359                    n_qp_solves,
360                    final_stationarity,
361                    final_constr_viol,
362                    working_set: Some(sol.working),
363                });
364            }
365            iter.x = ls.x_new;
366            for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
367                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
368            }
369            for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
370                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
371            }
372            iter.working = Some(sol.working);
373            nu = ls.nu;
374            f_cached = Some(ls.f_new);
375            c_cached = Some(ls.c_new);
376        }
377
378        let obj = nlp.eval_f(&iter.x);
379        self.iterates = Some(iter.clone());
380        Ok(SqpResult {
381            x: iter.x,
382            lambda_g: iter.lambda_g,
383            lambda_x: iter.lambda_x,
384            obj,
385            status: SqpStatus::MaxIter,
386            n_iter: self.opts.max_iter,
387            n_qp_solves,
388            final_stationarity,
389            final_constr_viol,
390            working_set: iter.working,
391        })
392    }
393
394    fn hessian_inertia(&self) -> HessianInertia {
395        match self.opts.hessian {
396            // Exact ∇²L is indefinite on nonconvex NLPs; let the
397            // QP solver's §4.5 inertia control handle it.
398            crate::sqp::SqpHessianSource::Exact => HessianInertia::Indefinite,
399            // Damped BFGS and L-BFGS are PSD by construction.
400            crate::sqp::SqpHessianSource::DampedBfgs => HessianInertia::Psd,
401            crate::sqp::SqpHessianSource::Lbfgs => HessianInertia::Psd,
402        }
403    }
404}
405
406#[derive(Debug, Clone, Copy)]
407struct KktError {
408    pub stationarity: Number,
409    pub constr_viol: Number,
410}
411
412/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
413/// current iterate. Used by the damped-BFGS update.
414fn compute_grad_lag(
415    grad_f: &[Number],
416    jac_c: &Triplet,
417    lambda_g: &[Number],
418    n: usize,
419) -> Vec<Number> {
420    let mut out = grad_f.to_vec();
421    debug_assert_eq!(out.len(), n);
422    for k in 0..jac_c.irow.len() {
423        let row_i = (jac_c.irow[k] - 1) as usize;
424        let col_j = (jac_c.jcol[k] - 1) as usize;
425        out[col_j] += jac_c.vals[k] * lambda_g[row_i];
426    }
427    out
428}
429
430fn check_kkt(
431    n: usize,
432    m: usize,
433    iter: &SqpIterates,
434    grad_f: &[Number],
435    c_vals: &[Number],
436    bl_c: &[Number],
437    bu_c: &[Number],
438    xl: &[Number],
439    xu: &[Number],
440    jac_c: &crate::sqp::qp_assembly::Triplet,
441) -> KktError {
442    // Constraint violation: max(0, bl - c, c - bu) on every row,
443    // plus bound violation on every variable.
444    let mut viol = 0.0_f64;
445    for i in 0..m {
446        let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
447            (bl_c[i] - c_vals[i]).max(0.0)
448        } else {
449            0.0
450        };
451        let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
452            (c_vals[i] - bu_c[i]).max(0.0)
453        } else {
454            0.0
455        };
456        viol = viol.max(lo).max(hi);
457    }
458    for i in 0..n {
459        let lo = if xl[i] > NLP_LOWER_BOUND_INF {
460            (xl[i] - iter.x[i]).max(0.0)
461        } else {
462            0.0
463        };
464        let hi = if xu[i] < NLP_UPPER_BOUND_INF {
465            (iter.x[i] - xu[i]).max(0.0)
466        } else {
467            0.0
468        };
469        viol = viol.max(lo).max(hi);
470    }
471
472    // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
473    // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
474    // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
475    // bound-multiplier sign, the variable-bound term enters the
476    // stationarity check with a negative sign — i.e. at the
477    // optimum `∇f + Jᵀ λ_g = λ_x`.
478    let mut stat = vec![0.0; n];
479    for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
480        *s = g;
481    }
482    // Add Jᵀ λ_g
483    for k in 0..jac_c.irow.len() {
484        let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
485        let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
486        stat[j] += jac_c.vals[k] * iter.lambda_g[i];
487    }
488    // Subtract λ_x
489    for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
490        *s -= lx;
491    }
492    let stat_max = stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
493
494    KktError {
495        stationarity: stat_max,
496        constr_viol: viol,
497    }
498}