Skip to main content

pounce_algorithm/sqp/
line_search.rs

1//! l1-merit backtracking line search for SQP. The classic
2//! Han-Powell scheme:
3//!
4//! ```text
5//!     φ(x; ν) = f(x) + ν · violation(x)
6//!     violation(x) = Σ_i max(bl_i − c_i, 0) + max(c_i − bu_i, 0)
7//!                  + Σ_j max(xl_j − x_j, 0) + max(x_j − xu_j, 0)
8//! ```
9//!
10//! Step `p` is a descent direction of `φ(·; ν)` whenever
11//! `ν ≥ ‖λ_g‖_∞` (Nocedal-Wright §18.4). We adapt `ν` at every
12//! iteration as `ν ← max(ν, ‖λ_g_qp‖_∞ + buffer)` so the QP-
13//! derived multipliers are always dominated.
14//!
15//! Backtracking is plain Armijo:
16//!
17//! ```text
18//!     φ(x + αp) ≤ φ(x) + η · α · D_p φ(x; ν)
19//! ```
20//!
21//! with predicted derivative
22//!
23//! ```text
24//!     D_p φ ≈ ∇f(x)ᵀ p − ν · violation(x)
25//! ```
26//!
27//! (correct under the standard assumption that the QP step
28//! reduces the linearized constraint violation to zero).
29//!
30//! Phase 5b commit 5 deliverable. The filter alternative
31//! (Fletcher-Leyffer 2002) is opt-in via
32//! `SqpGlobalization::Filter` and lands as a follow-up.
33
34use crate::sqp::options::SqpOptions;
35use crate::sqp::problem::SqpProblemSpec;
36use pounce_common::types::{Number, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
37
38/// l1 constraint + bound violation `‖max(bl − c, 0) + max(c − bu, 0)‖_1`
39/// (plus the same for variable bounds). Infinite bounds are treated
40/// as never violated.
41pub fn l1_violation(
42    x: &[Number],
43    c_vals: &[Number],
44    bl: &[Number],
45    bu: &[Number],
46    xl: &[Number],
47    xu: &[Number],
48) -> Number {
49    let mut v = 0.0_f64;
50    for (i, &ci) in c_vals.iter().enumerate() {
51        if bl[i] > NLP_LOWER_BOUND_INF {
52            v += (bl[i] - ci).max(0.0);
53        }
54        if bu[i] < NLP_UPPER_BOUND_INF {
55            v += (ci - bu[i]).max(0.0);
56        }
57    }
58    for (j, &xj) in x.iter().enumerate() {
59        if xl[j] > NLP_LOWER_BOUND_INF {
60            v += (xl[j] - xj).max(0.0);
61        }
62        if xu[j] < NLP_UPPER_BOUND_INF {
63            v += (xj - xu[j]).max(0.0);
64        }
65    }
66    v
67}
68
69pub struct LineSearchResult {
70    pub alpha: Number,
71    pub nu: Number,
72    pub x_new: Vec<Number>,
73    pub f_new: Number,
74    pub c_new: Vec<Number>,
75    pub success: bool,
76}
77
78/// Adapt `ν` against the QP multiplier magnitude and run Armijo
79/// backtracking on the l1 merit function. Returns the accepted
80/// step length, the updated ν, and the resulting trial state
81/// (`x_new`, `f_new`, `c_new`) so the caller doesn't have to
82/// re-evaluate.
83#[allow(clippy::too_many_arguments)]
84pub fn l1_merit_line_search<N: SqpProblemSpec>(
85    nlp: &mut N,
86    x: &[Number],
87    p: &[Number],
88    qp_lambda_g: &[Number],
89    grad_f: &[Number],
90    f_curr: Number,
91    c_curr: &[Number],
92    bl: &[Number],
93    bu: &[Number],
94    xl: &[Number],
95    xu: &[Number],
96    current_nu: Number,
97    opts: &SqpOptions,
98) -> LineSearchResult {
99    // ν adaptation (Han-Powell): dominate the QP multipliers by
100    // an additive safety margin, then clamp at l1_penalty_max so
101    // a pathological |λ_qp| spike doesn't blow the merit into a
102    // regime where Armijo always fails. Nocedal-Wright §18.4
103    // recommends `ν ≥ ‖λ‖_∞`; we use `+ l1_penalty_safety` to
104    // give the test a comfortable inequality.
105    let lambda_inf = qp_lambda_g.iter().map(|l| l.abs()).fold(0.0_f64, f64::max);
106    let nu = current_nu
107        .max(lambda_inf + opts.l1_penalty_safety)
108        .min(opts.l1_penalty_max);
109
110    let viol_curr = l1_violation(x, c_curr, bl, bu, xl, xu);
111    let phi_curr = f_curr + nu * viol_curr;
112
113    let grad_p: Number = grad_f.iter().zip(p.iter()).map(|(g, pi)| g * pi).sum();
114    // Predicted decrease: linear-objective contribution minus
115    // the violation we expect the QP to eliminate.
116    let predicted = grad_p - nu * viol_curr;
117    let eta = 1e-4_f64;
118
119    let mut alpha = 1.0_f64;
120    let mut x_trial = vec![0.0; x.len()];
121    let mut last_f = f_curr;
122    let mut last_c = c_curr.to_vec();
123    while alpha > opts.bt_min_alpha {
124        for (xt, (&xi, &pi)) in x_trial.iter_mut().zip(x.iter().zip(p.iter())) {
125            *xt = xi + alpha * pi;
126        }
127        let f_trial = nlp.eval_f(&x_trial);
128        let c_trial = nlp.eval_c(&x_trial);
129        let viol_trial = l1_violation(&x_trial, &c_trial, bl, bu, xl, xu);
130        let phi_trial = f_trial + nu * viol_trial;
131        last_f = f_trial;
132        last_c.clone_from(&c_trial);
133
134        let target = phi_curr + eta * alpha * predicted;
135        // Standard Armijo sufficient-decrease (Nocedal-Wright
136        // §3.1). The earlier `|| phi_trial < phi_curr` fallback
137        // (PR #50 review C3) accepted *any* descent and
138        // effectively bypassed the inequality on nonconvex
139        // problems where `predicted ≥ 0` makes the Armijo target
140        // monotone-non-decreasing. We now gate the fallback on
141        // `predicted >= 0` only — i.e. fall back to "any merit
142        // decrease wins" only when the predicted derivative is
143        // not a descent direction, which is the case the original
144        // fallback was intended to cover (cf. Wächter-Biegler 2006
145        // §3.3 backtracking rule).
146        let armijo_ok = if predicted < 0.0 {
147            phi_trial <= target
148        } else {
149            phi_trial < phi_curr
150        };
151        #[cfg(test)]
152        if opts.print_level >= 2 {
153            tracing::debug!(target: "pounce::sqp",
154                "         ls trial α={alpha:.3e} phi_t={phi_trial:.4e} \
155                 phi_c={phi_curr:.4e} target={target:.4e} pred={predicted:.3e} \
156                 grad_p={grad_p:.3e} viol_c={viol_curr:.3e} viol_t={viol_trial:.3e} \
157                 ok={armijo_ok}"
158            );
159        }
160        if armijo_ok {
161            return LineSearchResult {
162                alpha,
163                nu,
164                x_new: x_trial,
165                f_new: f_trial,
166                c_new: c_trial,
167                success: true,
168            };
169        }
170        alpha *= opts.bt_reduction;
171    }
172
173    LineSearchResult {
174        alpha,
175        nu,
176        x_new: x_trial,
177        f_new: last_f,
178        c_new: last_c,
179        success: false,
180    }
181}