Skip to main content

pounce_algorithm/line_search/
penalty_acceptor.rs

1//! Penalty line-search acceptor — port of `IpPenaltyLSAcceptor.{hpp,cpp}`.
2//!
3//! Phase 10. Backs `line_search_method = penalty`. Maintains a penalty
4//! parameter `ν` that's bumped up whenever the predicted reduction
5//! would otherwise be non-monotone:
6//!
7//! ```text
8//!   ν⁺ = (∇φᵀ δ + ½ δᵀ W δ) / ((1 − ρ) · θ)
9//!   if ν < ν⁺ then ν ← ν⁺ + ν_inc
10//! ```
11//!
12//! Acceptance test (Armijo on the penalty merit `M = φ + ν · θ`,
13//! upstream `IpPenaltyLSAcceptor.cpp:CheckAcceptabilityOfTrialPoint`):
14//!
15//! ```text
16//!   pred(α) = − α · ∇φᵀδ − ½ α² · δᵀWδ + ν · (θ − θ₂(α))
17//!   ared(α) = (φ_ref + ν · θ_ref) − (φ_trial + ν · θ_trial)
18//!   accept iff Compare_le(η · pred, ared, |φ_ref + ν · θ_ref|)
19//! ```
20//!
21//! where `θ₂(α)` is the 1-norm of the *linearised* constraint
22//! infeasibility at the predicted step:
23//!
24//! ```text
25//!   θ₂(α) = ‖c(x) + α · J_c · δx‖₁ + ‖d(x) − s + α · (J_d · δx − δs)‖₁
26//! ```
27//!
28//! `init_this_line_search` (driven by the backtracking driver before
29//! the α-loop) snapshots the reference state and the
30//! linearisation vectors, then runs `update_nu`. `check_trial_point`
31//! reads the snapshot to compute pred/ared per α — matching upstream
32//! lines 188-247.
33
34use crate::ipopt_cq::IpoptCqHandle;
35use crate::ipopt_data::IpoptDataHandle;
36use crate::iterates_vector::IteratesVector;
37use crate::line_search::filter_acceptor::AcceptDecision;
38use crate::line_search::ls_acceptor::BacktrackingLsAcceptor;
39use pounce_common::types::Number;
40use pounce_common::utils::compare_le;
41use pounce_linalg::Vector;
42use std::rc::Rc;
43
44pub struct PenaltyLsAcceptor {
45    /// Convex-combination weight ρ in upstream's update rule.
46    /// Default `0.1` per `IpPenaltyLSAcceptor.cpp:RegisterOptions`.
47    pub rho: Number,
48    /// Increment added when ν is bumped.
49    pub nu_inc: Number,
50    /// Initial value of ν.
51    pub nu_init: Number,
52    /// Max ν before declaring failure.
53    pub nu_max: Number,
54    /// Sufficient-decrease parameter η.
55    pub eta_penalty: Number,
56    nu: Number,
57    last_nu: Number,
58    /// Cached reference state — set by `init_this_line_search`,
59    /// consumed by `check_trial_point`.
60    cache: Option<RefCache>,
61}
62
63/// Reference-iterate snapshot needed by the pred/ared test.
64struct RefCache {
65    theta_ref: Number,
66    barr_ref: Number,
67    grad_barr_t_delta: Number,
68    dwd: Number,
69    /// `c(x)` at the reference iterate.
70    c_ref: Rc<dyn Vector>,
71    /// `d(x) − s` at the reference iterate.
72    d_minus_s_ref: Rc<dyn Vector>,
73    /// `J_c · δx`.
74    jac_c_delta: Rc<dyn Vector>,
75    /// `J_d · δx − δs`.
76    jac_d_delta_minus_ds: Rc<dyn Vector>,
77}
78
79impl Default for PenaltyLsAcceptor {
80    fn default() -> Self {
81        Self {
82            rho: 0.1,
83            nu_inc: 1e-4,
84            nu_init: 1e-6,
85            nu_max: 1e40,
86            eta_penalty: 1e-8,
87            nu: 1e-6,
88            last_nu: 1e-6,
89            cache: None,
90        }
91    }
92}
93
94impl PenaltyLsAcceptor {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    pub fn nu(&self) -> Number {
100        self.nu
101    }
102
103    pub fn last_nu(&self) -> Number {
104        self.last_nu
105    }
106
107    /// Reset to the initial ν. Called when the line search activates a
108    /// new outer iteration.
109    pub fn reset(&mut self) {
110        self.nu = self.nu_init;
111        self.last_nu = self.nu_init;
112        self.cache = None;
113    }
114
115    /// Scalar core of `IpPenaltyLSAcceptor.cpp:148-157`:
116    /// ```text
117    ///   if reference_theta > 0:
118    ///       ν⁺ = (gradBarrᵀδ + ½ δᵀWδ) / ((1 − ρ) · θ)
119    ///       if ν < ν⁺ then ν ← ν⁺ + ν_inc
120    /// ```
121    /// `last_nu` snapshots `ν` *before* the bump, matching upstream's
122    /// `last_nu_ = nu_`.
123    pub fn update_nu(
124        &mut self,
125        grad_barr_t_delta: Number,
126        delta_w_delta: Number,
127        reference_theta: Number,
128    ) {
129        self.last_nu = self.nu;
130        if reference_theta > 0.0 {
131            let nu_plus =
132                (grad_barr_t_delta + 0.5 * delta_w_delta) / ((1.0 - self.rho) * reference_theta);
133            if self.nu < nu_plus {
134                self.nu = nu_plus + self.nu_inc;
135            }
136        }
137    }
138
139    /// `pred(α)` from cached reference state. Upstream
140    /// `IpPenaltyLSAcceptor.cpp:CalcPred` lines 169-198. Returns 0 if
141    /// the closed-form value is negative.
142    fn calc_pred(&self, alpha: Number) -> Number {
143        let cache = self
144            .cache
145            .as_ref()
146            .expect("calc_pred called before init_this_line_search");
147        // theta_2(α) = ‖c + α·J_c·δx‖₁ + ‖d−s + α·(J_d·δx − δs)‖₁.
148        let mut tmp_c = cache.c_ref.make_new();
149        tmp_c.set(0.0);
150        tmp_c.add_two_vectors(1.0, &*cache.c_ref, alpha, &*cache.jac_c_delta, 0.0);
151        let mut tmp_d = cache.d_minus_s_ref.make_new();
152        tmp_d.set(0.0);
153        tmp_d.add_two_vectors(
154            1.0,
155            &*cache.d_minus_s_ref,
156            alpha,
157            &*cache.jac_d_delta_minus_ds,
158            0.0,
159        );
160        let theta_2 = tmp_c.asum() + tmp_d.asum();
161
162        let pred = -alpha * cache.grad_barr_t_delta - 0.5 * alpha * alpha * cache.dwd
163            + self.nu * (cache.theta_ref - theta_2);
164        if pred < 0.0 { 0.0 } else { pred }
165    }
166}
167
168impl BacktrackingLsAcceptor for PenaltyLsAcceptor {
169    fn reset(&mut self) {
170        PenaltyLsAcceptor::reset(self);
171    }
172
173    /// Snapshot reference state and bump ν once per outer iteration.
174    /// Mirrors upstream `IpPenaltyLSAcceptor.cpp:InitThisLineSearch`
175    /// lines 87-167 (non-watchdog branch).
176    fn init_this_line_search(
177        &mut self,
178        _data: &IpoptDataHandle,
179        cq: &IpoptCqHandle,
180        delta: &IteratesVector,
181    ) {
182        let cqr = cq.borrow();
183        let theta_ref = cqr.curr_constraint_violation();
184        let barr_ref = cqr.curr_barrier_obj();
185        let grad_barr_t_delta = cqr.curr_grad_barr_t_delta(&*delta.x, &*delta.s);
186        let dwd = cqr.curr_dwd(&*delta.x, &*delta.s);
187
188        // Linearisation vectors.
189        let c_ref = cqr.curr_c();
190        let d_minus_s_ref = cqr.curr_d_minus_s();
191        let jac_c_delta = cqr.curr_jac_c_times_vec(&*delta.x);
192        // jac_d_delta_minus_ds = J_d · δx − δs.
193        let jac_d_delta = cqr.curr_jac_d_times_vec(&*delta.x);
194        let mut tmp = jac_d_delta.make_new();
195        tmp.set(0.0);
196        tmp.add_two_vectors(1.0, &*jac_d_delta, -1.0, &*delta.s, 0.0);
197        let jac_d_delta_minus_ds: Rc<dyn Vector> = Rc::from(tmp);
198        drop(cqr);
199
200        self.cache = Some(RefCache {
201            theta_ref,
202            barr_ref,
203            grad_barr_t_delta,
204            dwd,
205            c_ref,
206            d_minus_s_ref,
207            jac_c_delta,
208            jac_d_delta_minus_ds,
209        });
210
211        // ν bump per `IpPenaltyLSAcceptor.cpp:148-157`.
212        self.update_nu(grad_barr_t_delta, dwd, theta_ref);
213    }
214
215    /// Sufficient-decrease test on the penalty merit
216    /// `M(x; ν) = φ + ν · θ`. Port of
217    /// `IpPenaltyLSAcceptor.cpp:CheckAcceptabilityOfTrialPoint` lines
218    /// 188-247:
219    ///
220    /// ```text
221    ///   pred = −α·∇φᵀδ − ½ α²·δᵀWδ + ν·(θ_ref − θ₂(α))
222    ///   ared = (φ_ref + ν·θ_ref) − (φ_trial + ν·θ_trial)
223    ///   accept iff Compare_le(η·pred, ared, |φ_ref + ν·θ_ref|)
224    /// ```
225    ///
226    /// The relaxed `≤` mirrors upstream's `Compare_le` ε-tolerance.
227    /// `Reject` falls through to the driver's α-reduction step.
228    fn check_trial_point(
229        &mut self,
230        alpha_primal: Number,
231        _theta: Number,
232        _phi: Number,
233        _d_phi: Number,
234        theta_trial: Number,
235        phi_trial: Number,
236    ) -> AcceptDecision {
237        // Without a fresh `init_this_line_search` snapshot we degenerate
238        // to "always accept" — the driver's reset path triggers this on
239        // the very first iteration before the acceptor has been wired.
240        let cache = match &self.cache {
241            Some(c) => c,
242            None => return AcceptDecision::Accept,
243        };
244
245        let pred = self.calc_pred(alpha_primal);
246        let ref_merit = cache.barr_ref + self.nu * cache.theta_ref;
247        let ared = ref_merit - (phi_trial + self.nu * theta_trial);
248
249        if compare_le(self.eta_penalty * pred, ared, ref_merit.abs()) {
250            AcceptDecision::Accept
251        } else {
252            AcceptDecision::Reject
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn no_bump_when_theta_zero() {
263        let mut a = PenaltyLsAcceptor::new();
264        let nu0 = a.nu();
265        a.update_nu(10.0, 5.0, 0.0);
266        assert_eq!(a.nu(), nu0);
267        assert_eq!(a.last_nu(), nu0);
268    }
269
270    #[test]
271    fn bump_when_nu_plus_exceeds_current() {
272        let mut a = PenaltyLsAcceptor {
273            rho: 0.1,
274            nu_inc: 1e-4,
275            nu: 0.0,
276            last_nu: 0.0,
277            ..Default::default()
278        };
279        // grad·δ + 0.5·δWδ = 1 + 0 = 1
280        // θ = 1; (1 − 0.1)·θ = 0.9 → ν⁺ ≈ 1.111…
281        a.update_nu(1.0, 0.0, 1.0);
282        assert!(a.last_nu() == 0.0);
283        let expected = 1.0 / 0.9 + 1e-4;
284        assert!((a.nu() - expected).abs() < 1e-12);
285    }
286
287    #[test]
288    fn no_bump_when_already_above_nu_plus() {
289        let mut a = PenaltyLsAcceptor {
290            rho: 0.1,
291            nu_inc: 1e-4,
292            nu: 1e6,
293            last_nu: 1e6,
294            ..Default::default()
295        };
296        a.update_nu(1.0, 0.0, 1.0);
297        assert_eq!(a.nu(), 1e6);
298    }
299
300    #[test]
301    fn reset_restores_init() {
302        let mut a = PenaltyLsAcceptor::new();
303        a.update_nu(10.0, 0.0, 1.0); // bumps ν
304        let bumped = a.nu();
305        assert!(bumped > a.nu_init);
306        PenaltyLsAcceptor::reset(&mut a);
307        assert_eq!(a.nu(), a.nu_init);
308    }
309
310    #[test]
311    fn check_trial_point_without_cache_accepts() {
312        // Driver-init path not yet exercised → fall-through accept.
313        let mut a = PenaltyLsAcceptor::new();
314        assert_eq!(
315            a.check_trial_point(1.0, 1.0, 10.0, -1.0, 0.5, 8.0),
316            AcceptDecision::Accept
317        );
318    }
319
320    /// Hand-built cache lets us exercise `calc_pred` and the
321    /// pred/ared decision without spinning up an IpoptCq.
322    fn cache_for_test(
323        theta_ref: Number,
324        barr_ref: Number,
325        grad_barr_t_delta: Number,
326        dwd: Number,
327        c_ref: Vec<Number>,
328        d_minus_s_ref: Vec<Number>,
329        jac_c_delta: Vec<Number>,
330        jac_d_delta_minus_ds: Vec<Number>,
331    ) -> RefCache {
332        use pounce_linalg::Vector;
333        use pounce_linalg::dense_vector::DenseVectorSpace;
334        let mkr = |v: Vec<Number>| -> Rc<dyn Vector> {
335            let mut x = DenseVectorSpace::new(v.len() as i32).make_new_dense();
336            x.values_mut().copy_from_slice(&v);
337            Rc::new(x)
338        };
339        RefCache {
340            theta_ref,
341            barr_ref,
342            grad_barr_t_delta,
343            dwd,
344            c_ref: mkr(c_ref),
345            d_minus_s_ref: mkr(d_minus_s_ref),
346            jac_c_delta: mkr(jac_c_delta),
347            jac_d_delta_minus_ds: mkr(jac_d_delta_minus_ds),
348        }
349    }
350
351    #[test]
352    fn calc_pred_matches_closed_form() {
353        // gradBarrᵀδ = 2; dWd = 4; ν = 0.5; θ_ref = 3.
354        // c = (1, 2); J_c·δx = (-1, -1) → c+α·J_c·δ at α=0.5 = (0.5, 1.5); ‖·‖₁ = 2.0.
355        // d−s = (4); J_d·δx − δs = (-2) → at α=0.5 = (3); ‖·‖₁ = 3.0.
356        // θ₂(0.5) = 5.0.
357        // pred = −0.5·2 − 0.5·0.25·4 + 0.5·(3 − 5) = −1 − 0.5 − 1 = −2.5 → clamps to 0.
358        let mut a = PenaltyLsAcceptor::new();
359        a.nu = 0.5;
360        a.cache = Some(cache_for_test(
361            3.0,
362            0.0,
363            2.0,
364            4.0,
365            vec![1.0, 2.0],
366            vec![4.0],
367            vec![-1.0, -1.0],
368            vec![-2.0],
369        ));
370        assert!((a.calc_pred(0.5) - 0.0).abs() < 1e-12);
371    }
372
373    #[test]
374    fn calc_pred_positive_when_directions_align() {
375        // gradBarrᵀδ = -2 (descent); dWd = 0; ν = 1; θ_ref = 3.
376        // J_c·δx = -c → at α=1, c+J_c·δ = 0 ⇒ θ₂ = 0.
377        // pred = −1·(−2) − 0 + 1·(3 − 0) = 2 + 3 = 5.
378        let mut a = PenaltyLsAcceptor::new();
379        a.nu = 1.0;
380        a.cache = Some(cache_for_test(
381            3.0,
382            0.0,
383            -2.0,
384            0.0,
385            vec![1.0, 2.0],
386            vec![0.0],
387            vec![-1.0, -2.0],
388            vec![0.0],
389        ));
390        assert!((a.calc_pred(1.0) - 5.0).abs() < 1e-12);
391    }
392
393    #[test]
394    fn check_trial_point_accepts_when_ared_meets_pred() {
395        // Reuse the descent setup: pred(1) = 5, η = 0.5 ⇒ η·pred = 2.5.
396        // φ_ref = 0; ν·θ_ref = 3 → ref_merit = 3.
397        // φ_trial = -3; ν·θ_trial = 0 → ared = 3 − (−3) = 6 ≥ 2.5 ⇒ Accept.
398        let mut a = PenaltyLsAcceptor::new();
399        a.nu = 1.0;
400        a.eta_penalty = 0.5;
401        a.cache = Some(cache_for_test(
402            3.0,
403            0.0,
404            -2.0,
405            0.0,
406            vec![1.0, 2.0],
407            vec![0.0],
408            vec![-1.0, -2.0],
409            vec![0.0],
410        ));
411        assert_eq!(
412            a.check_trial_point(1.0, 3.0, 0.0, -2.0, 0.0, -3.0),
413            AcceptDecision::Accept
414        );
415    }
416
417    #[test]
418    fn check_trial_point_rejects_insufficient_decrease() {
419        // Same descent setup, but trial has barely any improvement.
420        // ref_merit = 3; φ_trial + ν·θ_trial = 2.999 ⇒ ared ≈ 0.001 < η·pred = 2.5.
421        let mut a = PenaltyLsAcceptor::new();
422        a.nu = 1.0;
423        a.eta_penalty = 0.5;
424        a.cache = Some(cache_for_test(
425            3.0,
426            0.0,
427            -2.0,
428            0.0,
429            vec![1.0, 2.0],
430            vec![0.0],
431            vec![-1.0, -2.0],
432            vec![0.0],
433        ));
434        assert_eq!(
435            a.check_trial_point(1.0, 3.0, 0.0, -2.0, 2.999, 0.0),
436            AcceptDecision::Reject
437        );
438    }
439}