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::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
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 /// Present only when the accepted step was a second-order
77 /// correction. Carries the SOC subproblem's own multipliers
78 /// `(λ_g, λ_x)`, which the driver must adopt **verbatim**
79 /// instead of interpolating the original QP's multipliers: the
80 /// step actually taken is the SOC step, so a consistent
81 /// `(step, multipliers)` pair is required for the quasi-Newton
82 /// Hessian update to stay well-conditioned.
83 pub soc_duals: Option<(Vec<Number>, Vec<Number>)>,
84}
85
86/// A second-order correction returned by a [`SocProvider`]: the
87/// corrected full step together with the correction subproblem's
88/// own multipliers, so the driver can keep step and duals
89/// consistent.
90pub struct SocStep {
91 pub p: Vec<Number>,
92 pub lambda_g: Vec<Number>,
93 pub lambda_x: Vec<Number>,
94}
95
96/// Second-order-correction (SOC) provider — the Maratos remedy
97/// (Nocedal-Wright §18.11; Fletcher-Leyffer 2002). Given the
98/// constraint values `c(x + p)` at the rejected **full** step, it
99/// returns a corrected full step `p_soc` (recomputed against the
100/// constraint curvature at the trial point), or `None` if the
101/// correction subproblem could not be solved.
102///
103/// The line searches call it at most once, on the first (α = 1)
104/// trial, and only when that step *increases* the constraint
105/// violation — the signature of the Maratos effect, where a good
106/// Newton step is rejected because the linearized constraints
107/// under-predict the true (curved) violation. The provider itself
108/// is built by the SQP driver ([`crate::sqp::sqp_alg`]), which owns
109/// the QP solver and the linearization data.
110pub type SocProvider<'a> = &'a mut (dyn FnMut(&[Number]) -> Option<SocStep> + 'a);
111
112/// A second-order-correction step is accepted only if it reduces the
113/// constraint violation to at most this fraction of the uncorrected
114/// full-step violation (Wächter-Biegler 2006 §3.3, `κ_soc`). Keeps
115/// the SOC strictly a feasibility-improving correction, so a step
116/// that merely lowers the merit/objective without fixing the
117/// linearization error is rejected in favor of ordinary
118/// backtracking.
119pub(crate) const KAPPA_SOC: Number = 0.99;
120
121/// A second-order correction is a *small* perturbation of the QP
122/// step — the min-norm correction has `‖p̂‖ = O(‖p‖²)`, so near the
123/// solution `‖p_soc‖ ≈ ‖p‖`. We therefore reject any "correction"
124/// that grows the step beyond this multiple of `‖p‖_∞`: far from the
125/// solution, a quasi-Newton Hessian can make the re-solved SOC
126/// subproblem return a much longer step that overshoots and
127/// destabilizes the iteration, which is not what a correction should
128/// do. Bounding the growth keeps the Maratos remedy local without a
129/// separate trust region.
130pub(crate) const SOC_MAX_STEP_GROWTH: Number = 2.0;
131
132/// `‖v‖_∞`.
133pub(crate) fn inf_norm(v: &[Number]) -> Number {
134 v.iter().map(|x| x.abs()).fold(0.0_f64, f64::max)
135}
136
137/// Adapt `ν` against the QP multiplier magnitude and run Armijo
138/// backtracking on the l1 merit function. Returns the accepted
139/// step length, the updated ν, and the resulting trial state
140/// (`x_new`, `f_new`, `c_new`) so the caller doesn't have to
141/// re-evaluate.
142#[allow(clippy::too_many_arguments)]
143pub fn l1_merit_line_search<N: SqpProblemSpec>(
144 nlp: &mut N,
145 x: &[Number],
146 p: &[Number],
147 qp_lambda_g: &[Number],
148 grad_f: &[Number],
149 f_curr: Number,
150 c_curr: &[Number],
151 bl: &[Number],
152 bu: &[Number],
153 xl: &[Number],
154 xu: &[Number],
155 current_nu: Number,
156 opts: &SqpOptions,
157 mut soc: Option<SocProvider<'_>>,
158) -> LineSearchResult {
159 // ν adaptation (Han-Powell): dominate the QP multipliers by
160 // an additive safety margin, then clamp at l1_penalty_max so
161 // a pathological |λ_qp| spike doesn't blow the merit into a
162 // regime where Armijo always fails. Nocedal-Wright §18.4
163 // recommends `ν ≥ ‖λ‖_∞`; we use `+ l1_penalty_safety` to
164 // give the test a comfortable inequality.
165 let lambda_inf = qp_lambda_g.iter().map(|l| l.abs()).fold(0.0_f64, f64::max);
166 let nu = current_nu
167 .max(lambda_inf + opts.l1_penalty_safety)
168 .min(opts.l1_penalty_max);
169
170 let viol_curr = l1_violation(x, c_curr, bl, bu, xl, xu);
171 let phi_curr = f_curr + nu * viol_curr;
172
173 let grad_p: Number = grad_f.iter().zip(p.iter()).map(|(g, pi)| g * pi).sum();
174 // Predicted decrease: linear-objective contribution minus
175 // the violation we expect the QP to eliminate.
176 let predicted = grad_p - nu * viol_curr;
177 let eta = 1e-4_f64;
178
179 let mut alpha = 1.0_f64;
180 let mut x_trial = vec![0.0; x.len()];
181 let mut last_f = f_curr;
182 let mut last_c = c_curr.to_vec();
183 let mut first_trial = true;
184 while alpha > opts.bt_min_alpha {
185 for (xt, (&xi, &pi)) in x_trial.iter_mut().zip(x.iter().zip(p.iter())) {
186 *xt = xi + alpha * pi;
187 }
188 let f_trial = nlp.eval_f(&x_trial);
189 let c_trial = nlp.eval_c(&x_trial);
190 let viol_trial = l1_violation(&x_trial, &c_trial, bl, bu, xl, xu);
191 let phi_trial = f_trial + nu * viol_trial;
192 last_f = f_trial;
193 last_c.clone_from(&c_trial);
194
195 let target = phi_curr + eta * alpha * predicted;
196 // Standard Armijo sufficient-decrease (Nocedal-Wright
197 // §3.1). The earlier `|| phi_trial < phi_curr` fallback
198 // (PR #50 review C3) accepted *any* descent and
199 // effectively bypassed the inequality on nonconvex
200 // problems where `predicted ≥ 0` makes the Armijo target
201 // monotone-non-decreasing. We now gate the fallback on
202 // `predicted >= 0` only — i.e. fall back to "any merit
203 // decrease wins" only when the predicted derivative is
204 // not a descent direction, which is the case the original
205 // fallback was intended to cover (cf. Wächter-Biegler 2006
206 // §3.3 backtracking rule).
207 let armijo_ok = if predicted < 0.0 {
208 phi_trial <= target
209 } else {
210 phi_trial < phi_curr
211 };
212 #[cfg(test)]
213 if opts.print_level >= 2 {
214 tracing::debug!(target: "pounce::sqp",
215 " ls trial α={alpha:.3e} phi_t={phi_trial:.4e} \
216 phi_c={phi_curr:.4e} target={target:.4e} pred={predicted:.3e} \
217 grad_p={grad_p:.3e} viol_c={viol_curr:.3e} viol_t={viol_trial:.3e} \
218 ok={armijo_ok}"
219 );
220 }
221 if armijo_ok {
222 return LineSearchResult {
223 alpha,
224 nu,
225 x_new: x_trial,
226 f_new: f_trial,
227 c_new: c_trial,
228 success: true,
229 soc_duals: None,
230 };
231 }
232
233 // Second-order correction (Maratos remedy). Attempted once,
234 // on the full step (α = 1), and only when that step made the
235 // constraint violation worse — otherwise a smaller α already
236 // makes ordinary progress and no correction is needed. The
237 // corrected full step `x + p_soc` is tested against the same
238 // Armijo condition; on rejection we discard it and fall back
239 // to plain backtracking on the original direction `p`.
240 if first_trial {
241 first_trial = false;
242 if viol_trial > viol_curr {
243 if let Some(soc_fn) = soc.as_deref_mut() {
244 if let Some(step) = soc_fn(&c_trial) {
245 // Reject an overshooting "correction" (see
246 // [`SOC_MAX_STEP_GROWTH`]) before spending an
247 // evaluation on it.
248 if inf_norm(&step.p) <= SOC_MAX_STEP_GROWTH * inf_norm(p) {
249 let mut x_soc = vec![0.0; x.len()];
250 for (xt, (&xi, &pi)) in
251 x_soc.iter_mut().zip(x.iter().zip(step.p.iter()))
252 {
253 *xt = xi + pi;
254 }
255 let f_soc = nlp.eval_f(&x_soc);
256 let c_soc = nlp.eval_c(&x_soc);
257 let viol_soc = l1_violation(&x_soc, &c_soc, bl, bu, xl, xu);
258 let phi_soc = f_soc + nu * viol_soc;
259 // Same Armijo gate as above, at α = 1.
260 let armijo_soc = if predicted < 0.0 {
261 phi_soc <= phi_curr + eta * predicted
262 } else {
263 phi_soc < phi_curr
264 };
265 // Feasibility safeguard (Wächter-Biegler
266 // 2006 §3.3): only take the correction if
267 // it genuinely reduces the constraint
268 // violation relative to the uncorrected
269 // full step — otherwise it is not a
270 // second-order *correction* and we fall
271 // back to ordinary backtracking on `p`.
272 let soc_ok = armijo_soc && viol_soc <= KAPPA_SOC * viol_trial;
273 if soc_ok {
274 return LineSearchResult {
275 alpha: 1.0,
276 nu,
277 x_new: x_soc,
278 f_new: f_soc,
279 c_new: c_soc,
280 success: true,
281 soc_duals: Some((step.lambda_g, step.lambda_x)),
282 };
283 }
284 }
285 }
286 }
287 }
288 }
289
290 alpha *= opts.bt_reduction;
291 }
292
293 LineSearchResult {
294 alpha,
295 nu,
296 x_new: x_trial,
297 f_new: last_f,
298 c_new: last_c,
299 success: false,
300 soc_duals: None,
301 }
302}