Skip to main content

pounce_algorithm/line_search/
backtracking.rs

1//! Backtracking line-search driver — port of
2//! `Algorithm/IpBacktrackingLineSearch.{hpp,cpp}`.
3//!
4//! Owns the alpha-reduction loop, max-soc / second-order-correction
5//! slot, watchdog mechanism, and the fallback to restoration. Phase 7
6//! ships the alpha-loop for the filter line search; SOC and watchdog
7//! land alongside the restoration phase (Phase 9).
8//!
9//! The contract with the acceptor is the trio
10//! `(theta, phi, d_phi)` at the current iterate plus the trial
11//! `(theta_trial, phi_trial)` per backtracking step. Trial-point
12//! construction is `x_trial = x + α·dx`, `s_trial = s + α·ds`; the dual
13//! step uses the same α for the filter acceptor (upstream
14//! `IpBacktrackingLineSearch.cpp:702-728` — primal-dual share α
15//! when no fraction-to-the-boundary truncation differs).
16//!
17//! `find_acceptable_trial_point` returns `Outcome::Accepted` on a
18//! successful trial, `Outcome::TinyStep` when α drops below
19//! `alpha_min`, and `Outcome::Failed` when the alpha loop exhausts
20//! without acceptance (which the main loop maps to a restoration
21//! attempt).
22
23use crate::ipopt_cq::IpoptCqHandle;
24use crate::ipopt_data::IpoptDataHandle;
25use crate::ipopt_nlp::IpoptNlp;
26use crate::iterates_vector::IteratesVector;
27use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
28use crate::line_search::filter_acceptor::AcceptDecision;
29use crate::line_search::ls_acceptor::BacktrackingLsAcceptor;
30use pounce_common::types::Number;
31use std::cell::RefCell;
32use std::rc::Rc;
33
34/// Outcome of the backtracking line search. Mirrors the booleans
35/// upstream returns through `accept_` plus the `tiny_step_flag` on
36/// `IpoptData`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Outcome {
39    /// Trial point accepted at the recorded `alpha`.
40    Accepted,
41    /// `alpha` fell below `alpha_min_frac` × current α₀ ⇒ tiny step.
42    /// Caller maps to `STEP_BECOMES_TINY` in upstream's exception flow.
43    TinyStep,
44    /// All α reductions rejected; the caller hands off to restoration.
45    Failed,
46}
47
48pub struct BacktrackingLineSearch {
49    pub acceptor: Box<dyn BacktrackingLsAcceptor>,
50    pub alpha_red_factor: Number,
51    pub max_soc: i32,
52    /// Threshold for the SOC outer-loop convergence test
53    /// `theta_trial <= kappa_soc * theta_soc_old`. Mirrors upstream's
54    /// `kappa_soc` (default 0.99).
55    pub kappa_soc: Number,
56    /// SOC RHS variant. `0` = upstream default ("old"), `1` = scaled
57    /// gradient-block variant. Both correspond to upstream's
58    /// `soc_method` option.
59    pub soc_method: i32,
60    /// Number of consecutive shortened iterations before the watchdog
61    /// procedure activates. Disabled when `<= 0`. Mirrors upstream's
62    /// `watchdog_shortened_iter_trigger` (default 10).
63    pub watchdog_shortened_iter_trigger: i32,
64    /// Maximum number of outer iterations the watchdog will accept
65    /// non-decreasing trial points before reverting to the snapshot.
66    /// Mirrors upstream's `watchdog_trial_iter_max` (default 3).
67    pub watchdog_trial_iter_max: i32,
68    /// Lower bound on α; below this we declare a tiny step (mirrors
69    /// `alpha_min_frac` flow, `IpBacktrackingLineSearch.cpp:CalculateAlphaMin`).
70    pub alpha_min: Number,
71    /// Maximum trial-iteration cap before declaring failure.
72    pub max_trials: i32,
73
74    // ---- Watchdog state (port of `IpBacktrackingLineSearch.{hpp,cpp}`'s
75    //      `in_watchdog_`, `watchdog_iterate_`, `watchdog_delta_`,
76    //      `watchdog_alpha_primal_test_`, `watchdog_trial_iter_`,
77    //      `watchdog_shortened_iter_`, `last_mu_`).
78    //
79    // Watchdog mechanism: after `watchdog_shortened_iter_trigger`
80    // consecutive shortened (n_steps > 0) accepts, we snapshot the
81    // current iterate `(curr, delta, theta, phi, d_phi)` and enter
82    // watchdog mode. While in watchdog: the acceptor's reference
83    // values are FROZEN to the snapshot for up to
84    // `watchdog_trial_iter_max` outer iterations. Each iteration's
85    // alpha-loop runs against the frozen reference; if it accepts,
86    // watchdog terminates with success ("W"). If it rejects, we
87    // accept the last trial anyway (info char 'w') and let the next
88    // outer iteration try again. If `watchdog_trial_iter_max` outer
89    // iterations all reject, we revert to the snapshot and re-run
90    // the alpha-loop on the saved `delta` with `skip_first=true`.
91    /// True iff currently inside a watchdog window.
92    in_watchdog: bool,
93    /// Snapshot of the iterate at watchdog activation.
94    watchdog_iterate: Option<IteratesVector>,
95    /// Snapshot of the search direction at watchdog activation.
96    watchdog_delta: Option<IteratesVector>,
97    /// Snapshot of `primal_frac_to_the_bound(τ, δ)` at watchdog
98    /// activation. Currently unused inside the alpha loop (pounce's
99    /// driver passes `alpha_init` directly), but stored for parity
100    /// with upstream's iter-by-iter trace.
101    #[allow(dead_code)]
102    watchdog_alpha_primal_test: Number,
103    /// Number of outer iterations elapsed since watchdog activation.
104    watchdog_trial_iter: i32,
105    /// Number of consecutive shortened (n_steps > 0) accepts.
106    /// Reset on a full step (n_steps == 0), on mu change, on watchdog
107    /// success, and on watchdog stop-with-revert.
108    watchdog_shortened_iter: i32,
109    /// `mu` at the previous outer iteration. A change clears the
110    /// watchdog state (`IpBacktrackingLineSearch.cpp:259-270`).
111    last_mu: Number,
112    /// Frozen reference theta at watchdog activation.
113    watchdog_theta: Number,
114    /// Frozen reference phi at watchdog activation.
115    watchdog_phi: Number,
116    /// Frozen reference d_phi at watchdog activation.
117    watchdog_d_phi: Number,
118
119    // ---- Soft restoration phase (port of `IpBacktrackingLineSearch`'s
120    //      `in_soft_resto_phase_`, `soft_resto_counter_`).
121    //
122    // When the regular filter line search fails, before handing off to
123    // the full (sub-NLP) restoration phase, the driver tries a single
124    // damped primal-dual step along the *same* search direction. The
125    // step is damped only by the fraction-to-the-boundary rule and is
126    // accepted if it either satisfies the original filter criterion
127    // ('S' — leave soft resto) or merely reduces the primal-dual KKT
128    // system error by `soft_resto_pderror_reduction_factor` ('s' —
129    // stay in soft resto). Subsequent outer iterations keep taking
130    // soft-resto steps until the original criterion is met, the step
131    // is rejected, or `max_soft_resto_iters` consecutive iterations
132    // elapse — any of which drops through to full restoration.
133    /// Required relative reduction in the primal-dual system error for
134    /// a soft-resto step to be accepted. `0` disables soft restoration.
135    /// Mirrors upstream `soft_resto_pderror_reduction_factor`
136    /// (default `1 - 1e-4`).
137    pub soft_resto_pderror_reduction_factor: Number,
138    /// Cap on consecutive soft-resto iterations before full
139    /// restoration is forced. Mirrors upstream `max_soft_resto_iters`
140    /// (default 10).
141    pub max_soft_resto_iters: i32,
142    /// True iff the driver is currently inside the soft-resto phase.
143    in_soft_resto_phase: bool,
144    /// Count of consecutive soft-resto iterations taken so far.
145    soft_resto_counter: i32,
146}
147
148/// Internal alpha-loop outcome. The watchdog wrapper translates this
149/// into the public [`Outcome`] after applying its state machine.
150enum AlphaResult {
151    /// Trial accepted at `alpha_used` after `n_steps` reductions.
152    Accepted { n_steps: i32 },
153    /// α dropped below `alpha_min_eff` ⇒ tiny step. `last_alpha` is
154    /// the smallest α actually evaluated; `n_steps` is the number of
155    /// reductions performed.
156    TinyStep { n_steps: i32, last_alpha: Number },
157    /// `max_trials` exhausted without acceptance. The last attempted
158    /// trial iterate is left in `data.trial` so the watchdog
159    /// "accept-anyway" path can promote it.
160    ///
161    /// `evaluation_error` flags that the last attempted trial produced
162    /// a non-finite `theta_trial`/`phi_trial` — mirrors upstream's
163    /// `evaluation_error` tracked from `IpoptNLP::Eval_Error`
164    /// (`IpBacktrackingLineSearch.cpp:776-784`). The watchdog handler
165    /// must treat this as a forced StopWatchDog
166    /// (`IpBacktrackingLineSearch.cpp:493`) — accepting a non-finite
167    /// iterate via the 'w' branch propagates NaN/Inf into the next
168    /// outer iter (observed on PFIT3 iter 53: inf_pr=7.87e305 from a
169    /// 'w'-accepted trial; on PFIT4 iter 31: inf_pr=1.01e11).
170    Failed {
171        n_steps: i32,
172        last_alpha: Number,
173        evaluation_error: bool,
174    },
175}
176
177impl BacktrackingLineSearch {
178    pub fn new(acceptor: Box<dyn BacktrackingLsAcceptor>) -> Self {
179        Self {
180            acceptor,
181            alpha_red_factor: 0.5,
182            max_soc: 4,
183            kappa_soc: 0.99,
184            soc_method: 0,
185            watchdog_shortened_iter_trigger: 10,
186            watchdog_trial_iter_max: 3,
187            alpha_min: 1e-12,
188            max_trials: 50,
189            in_watchdog: false,
190            watchdog_iterate: None,
191            watchdog_delta: None,
192            watchdog_alpha_primal_test: 0.0,
193            watchdog_trial_iter: 0,
194            watchdog_shortened_iter: 0,
195            last_mu: -1.0,
196            watchdog_theta: 0.0,
197            watchdog_phi: 0.0,
198            watchdog_d_phi: 0.0,
199            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
200            max_soft_resto_iters: 10,
201            in_soft_resto_phase: false,
202            soft_resto_counter: 0,
203        }
204    }
205
206    /// Test-only accessor for the watchdog active flag.
207    #[cfg(test)]
208    pub(crate) fn in_watchdog(&self) -> bool {
209        self.in_watchdog
210    }
211
212    /// Test-only accessor for the shortened-iter counter.
213    #[cfg(test)]
214    pub(crate) fn watchdog_shortened_iter(&self) -> i32 {
215        self.watchdog_shortened_iter
216    }
217
218    pub fn acceptor(&self) -> &dyn BacktrackingLsAcceptor {
219        &*self.acceptor
220    }
221
222    pub fn acceptor_mut(&mut self) -> &mut dyn BacktrackingLsAcceptor {
223        &mut *self.acceptor
224    }
225
226    /// Reset the acceptor state at the start of a new outer iteration.
227    pub fn reset(&mut self) {
228        self.acceptor.reset();
229    }
230
231    /// Public line-search entry point. Wraps the regular filter line
232    /// search ([`Self::run_filter_line_search`]) with the soft
233    /// restoration phase — port of the `in_soft_resto_phase_` state
234    /// machine in `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
235    /// (`IpBacktrackingLineSearch.cpp:439-465` for the in-phase
236    /// continuation, `:528-556` for entering the phase).
237    ///
238    /// Outcomes:
239    /// - `Accepted`: a trial point is in `data.trial` — either a
240    ///   regular filter/watchdog step or a soft-resto step (info char
241    ///   's' = stay in soft resto, 'S' = step also satisfies the
242    ///   original filter so soft resto is left).
243    /// - `TinyStep` / `Failed`: neither the regular line search nor a
244    ///   soft-resto step could make progress; the caller hands off to
245    ///   the full restoration phase.
246    #[allow(clippy::too_many_arguments)]
247    pub fn find_acceptable_trial_point(
248        &mut self,
249        data: &IpoptDataHandle,
250        cq: &IpoptCqHandle,
251        delta: &IteratesVector,
252        alpha_init: Number,
253        alpha_dual: Number,
254        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
255        search_dir: Option<&mut PdSearchDirCalc>,
256    ) -> Outcome {
257        // ---- Soft-resto continuation. Already inside the phase: bump
258        // the counter, bail to full restoration once it exceeds
259        // `max_soft_resto_iters`, otherwise take another damped
260        // primal-dual step along the caller's `delta`
261        // (`IpBacktrackingLineSearch.cpp:439-465`).
262        if self.in_soft_resto_phase {
263            self.soft_resto_counter += 1;
264            if self.soft_resto_counter > self.max_soft_resto_iters {
265                self.in_soft_resto_phase = false;
266                self.soft_resto_counter = 0;
267                return self.fail_to_restoration(data);
268            }
269            // Per-outer-iteration acceptor hook (no-op for the filter
270            // acceptor; the penalty acceptor caches its reference here).
271            self.acceptor.init_this_line_search(data, cq, delta);
272            return match self.try_soft_resto_step(data, cq, delta) {
273                Some(satisfies_original) => {
274                    if satisfies_original {
275                        self.in_soft_resto_phase = false;
276                        self.soft_resto_counter = 0;
277                        data.borrow_mut().info_alpha_primal_char = 'S';
278                    } else {
279                        data.borrow_mut().info_alpha_primal_char = 's';
280                    }
281                    Outcome::Accepted
282                }
283                None => {
284                    self.in_soft_resto_phase = false;
285                    self.soft_resto_counter = 0;
286                    self.fail_to_restoration(data)
287                }
288            };
289        }
290
291        // ---- Regular filter line search (watchdog + alpha loop).
292        let outcome =
293            self.run_filter_line_search(data, cq, delta, alpha_init, alpha_dual, nlp, search_dir);
294        if outcome == Outcome::Accepted {
295            return Outcome::Accepted;
296        }
297
298        // ---- Regular line search failed. Before the (expensive) full
299        // restoration sub-NLP, try to *enter* the soft restoration
300        // phase with one damped primal-dual step
301        // (`IpBacktrackingLineSearch.cpp:528-556`). `prepare_resto_phase_start`
302        // augments the outer filter with the entry envelope — mirrors
303        // upstream's `acceptor_->PrepareRestoPhaseStart()` at line 537.
304        let reference_theta = cq.borrow().curr_constraint_violation();
305        let reference_barr = cq.borrow().curr_barrier_obj();
306        self.acceptor
307            .prepare_resto_phase_start(reference_theta, reference_barr);
308        match self.try_soft_resto_step(data, cq, delta) {
309            Some(satisfies_original) => {
310                if satisfies_original {
311                    data.borrow_mut().info_alpha_primal_char = 'S';
312                } else {
313                    self.in_soft_resto_phase = true;
314                    self.soft_resto_counter = 0;
315                    data.borrow_mut().info_alpha_primal_char = 's';
316                }
317                Outcome::Accepted
318            }
319            // Soft resto could not help — fall through to full
320            // restoration with the original failure outcome. The
321            // caller's `invoke_restoration` re-runs
322            // `prepare_resto_phase_start`; the duplicate filter
323            // augmentation is idempotent (same envelope).
324            None => outcome,
325        }
326    }
327
328    /// Stamp the info fields for a hand-off to the full restoration
329    /// phase and return `Outcome::Failed`. Used when the soft
330    /// restoration phase exhausts its iteration budget or its step is
331    /// rejected mid-phase.
332    fn fail_to_restoration(&self, data: &IpoptDataHandle) -> Outcome {
333        let mut d = data.borrow_mut();
334        d.trial = None;
335        d.info_alpha_primal = 0.0;
336        d.info_alpha_dual = 0.0;
337        d.info_alpha_primal_char = 'R';
338        d.info_ls_count = 0;
339        Outcome::Failed
340    }
341
342    /// Attempt a single damped primal-dual step for the soft
343    /// restoration phase — port of
344    /// `BacktrackingLineSearch::TrySoftRestoStep`
345    /// (`IpBacktrackingLineSearch.cpp:1112-1217`). The step along
346    /// `delta` is damped only by the fraction-to-the-boundary rule,
347    /// with an identical step length for primal and dual variables.
348    ///
349    /// Returns:
350    /// - `Some(true)`  — trial accepted *and* it satisfies the
351    ///   original filter criterion ⇒ caller leaves soft resto ('S').
352    /// - `Some(false)` — trial accepted only on the primal-dual error
353    ///   reduction test ⇒ caller stays in soft resto ('s').
354    /// - `None`        — trial rejected (or soft resto disabled / a
355    ///   non-finite evaluation) ⇒ caller falls through to the full
356    ///   restoration phase.
357    ///
358    /// On a `Some(_)` return the accepted trial is left in `data.trial`
359    /// and the numeric `info_*` fields are stamped; the caller stamps
360    /// `info_alpha_primal_char`.
361    fn try_soft_resto_step(
362        &mut self,
363        data: &IpoptDataHandle,
364        cq: &IpoptCqHandle,
365        delta: &IteratesVector,
366    ) -> Option<bool> {
367        // Soft restoration is disabled when the reduction factor is
368        // zero (`IpBacktrackingLineSearch.cpp:1124`).
369        if self.soft_resto_pderror_reduction_factor == 0.0 {
370            return None;
371        }
372        let curr = data.borrow().curr.clone()?;
373        let tau = data.borrow().curr_tau;
374
375        // Identical step length for primal and dual variables, damped
376        // only by the fraction-to-the-boundary rule
377        // (`IpBacktrackingLineSearch.cpp:1135-1140`).
378        let alpha = {
379            let cq_ref = cq.borrow();
380            cq_ref
381                .aff_step_alpha_primal_max(delta, tau)
382                .min(cq_ref.aff_step_alpha_dual_max(delta, tau))
383        };
384
385        let trial_iv = scaled_step(&curr, delta, alpha, alpha);
386        data.borrow_mut().set_trial(trial_iv);
387
388        let theta_trial = cq.borrow().trial_constraint_violation();
389        let phi_trial = cq.borrow().trial_barrier_obj();
390        if !theta_trial.is_finite() || !phi_trial.is_finite() {
391            // Upstream retries up to three times on `Eval_Error`; the
392            // step length is fixed, so a non-finite eval here is
393            // deterministic — treat it as a rejection.
394            return None;
395        }
396
397        let theta = cq.borrow().curr_constraint_violation();
398        let phi = cq.borrow().curr_barrier_obj();
399        let d_phi = self.compute_d_phi(cq, delta);
400
401        // First test: is the trial acceptable to the *original*
402        // backtracking globalization? Upstream
403        // `acceptor_->CheckAcceptabilityOfTrialPoint(0.)`.
404        if self
405            .acceptor
406            .check_trial_point(0.0, theta, phi, d_phi, theta_trial, phi_trial)
407            == AcceptDecision::Accept
408        {
409            let mut d = data.borrow_mut();
410            d.info_alpha_primal = alpha;
411            d.info_alpha_dual = alpha;
412            d.info_ls_count = 1;
413            return Some(true);
414        }
415
416        // Second test: sufficient reduction in the primal-dual KKT
417        // system error (`IpBacktrackingLineSearch.cpp:1184-1211`).
418        let mu = data.borrow().curr_mu;
419        let curr_pderror = cq.borrow().curr_primal_dual_system_error(mu);
420        let trial_pderror = cq.borrow().trial_primal_dual_system_error(mu);
421        if !trial_pderror.is_finite() {
422            return None;
423        }
424        if trial_pderror <= self.soft_resto_pderror_reduction_factor * curr_pderror {
425            let mut d = data.borrow_mut();
426            d.info_alpha_primal = alpha;
427            d.info_alpha_dual = alpha;
428            d.info_ls_count = 1;
429            return Some(false);
430        }
431        None
432    }
433
434    /// Drive the watchdog state machine + alpha-reduction loop.
435    /// Port of `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
436    /// (`IpBacktrackingLineSearch.cpp:252-677`) restricted to the
437    /// regular (non-soft-resto) filter-acceptor, exact-Hessian path.
438    /// The soft restoration phase is layered on top by
439    /// [`Self::find_acceptable_trial_point`].
440    ///
441    /// Outcomes:
442    /// - `Accepted`: a trial point is in `data.trial`, info fields are
443    ///   stamped. The watchdog state has been advanced (success → "W",
444    ///   `accept-anyway` → 'w').
445    /// - `TinyStep`: α dropped below the dynamic alpha-min before any
446    ///   trial was accepted. Caller hands off to restoration.
447    /// - `Failed`: alpha-loop exhausted AND watchdog could not rescue.
448    ///   Caller hands off to restoration.
449    #[allow(clippy::too_many_arguments)]
450    fn run_filter_line_search(
451        &mut self,
452        data: &IpoptDataHandle,
453        cq: &IpoptCqHandle,
454        delta: &IteratesVector,
455        alpha_init: Number,
456        alpha_dual: Number,
457        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
458        search_dir: Option<&mut PdSearchDirCalc>,
459    ) -> Outcome {
460        // ---- Watchdog: detect mu change → reset state.
461        // Mirrors `IpBacktrackingLineSearch.cpp:259-270`.
462        let curr_mu = data.borrow().curr_mu;
463        if self.last_mu < 0.0 || self.last_mu != curr_mu {
464            self.in_watchdog = false;
465            self.watchdog_iterate = None;
466            self.watchdog_delta = None;
467            self.watchdog_shortened_iter = 0;
468            self.last_mu = curr_mu;
469        }
470
471        // ---- Watchdog: maybe wake up.
472        // Mirrors `IpBacktrackingLineSearch.cpp:376-380`.
473        if !self.in_watchdog
474            && self.watchdog_shortened_iter_trigger > 0
475            && self.watchdog_shortened_iter >= self.watchdog_shortened_iter_trigger
476        {
477            self.start_watchdog(data, cq, delta);
478        }
479
480        // Per-outer-iteration acceptor hook.
481        self.acceptor.init_this_line_search(data, cq, delta);
482
483        // Decide reference (theta, phi, d_phi). Mirrors upstream's
484        // `FilterLSAcceptor::InitThisLineSearch(in_watchdog)` choice
485        // between `curr_*` and the saved `watchdog_*` snapshot.
486        let (theta, phi, d_phi) = if self.in_watchdog {
487            (self.watchdog_theta, self.watchdog_phi, self.watchdog_d_phi)
488        } else {
489            let theta = cq.borrow().curr_constraint_violation();
490            let phi = cq.borrow().curr_barrier_obj();
491            let d_phi = self.compute_d_phi(cq, delta);
492            (theta, phi, d_phi)
493        };
494
495        // Run the alpha-loop on the caller's `delta`.
496        let result = self.run_alpha_loop(
497            data, cq, delta, alpha_init, alpha_dual, nlp, search_dir, theta, phi, d_phi,
498            /*skip_first*/ false,
499        );
500
501        match result {
502            AlphaResult::Accepted { n_steps } => {
503                // Update the shortened-iter counter
504                // (`IpBacktrackingLineSearch.cpp:644-655`).
505                if n_steps == 0 {
506                    self.watchdog_shortened_iter = 0;
507                } else {
508                    self.watchdog_shortened_iter += 1;
509                }
510                if self.in_watchdog {
511                    // Watchdog success — clear state, info char already
512                    // stamped by the alpha loop's
513                    // `update_for_next_iteration` call. Upstream also
514                    // appends "W" to the info string here; pounce
515                    // doesn't track an info string yet.
516                    self.in_watchdog = false;
517                    self.watchdog_iterate = None;
518                    self.watchdog_delta = None;
519                    self.watchdog_shortened_iter = 0;
520                }
521                Outcome::Accepted
522            }
523            AlphaResult::TinyStep {
524                n_steps,
525                last_alpha,
526            } => {
527                let mut d = data.borrow_mut();
528                d.trial = None;
529                d.info_alpha_primal = last_alpha;
530                d.info_alpha_dual = 0.0;
531                d.info_alpha_primal_char = 'R';
532                d.info_ls_count = n_steps + 1;
533                Outcome::TinyStep
534            }
535            AlphaResult::Failed {
536                n_steps,
537                last_alpha,
538                evaluation_error,
539            } => {
540                if self.in_watchdog {
541                    self.handle_watchdog_failure(
542                        data,
543                        cq,
544                        alpha_init,
545                        alpha_dual,
546                        nlp,
547                        n_steps,
548                        last_alpha,
549                        evaluation_error,
550                    )
551                } else {
552                    // Genuine failure → restoration.
553                    let mut d = data.borrow_mut();
554                    d.trial = None;
555                    d.info_alpha_primal = last_alpha;
556                    d.info_alpha_dual = 0.0;
557                    d.info_alpha_primal_char = 'R';
558                    d.info_ls_count = n_steps + 1;
559                    Outcome::Failed
560                }
561            }
562        }
563    }
564
565    /// Snapshot the current `(curr, delta, theta, phi, d_phi)` and
566    /// activate the watchdog. Mirrors upstream
567    /// `IpBacktrackingLineSearch::StartWatchDog`
568    /// (`IpBacktrackingLineSearch.cpp:855-869`) plus
569    /// `IpFilterLSAcceptor::StartWatchDog`
570    /// (`IpFilterLSAcceptor.cpp:506-513`) — pounce stores the
571    /// frozen reference values directly on the driver because the
572    /// acceptor is stateless w.r.t. reference values (the driver
573    /// passes them per call).
574    fn start_watchdog(
575        &mut self,
576        data: &IpoptDataHandle,
577        cq: &IpoptCqHandle,
578        delta: &IteratesVector,
579    ) {
580        let curr = data.borrow().curr.clone();
581        let Some(curr) = curr else {
582            return;
583        };
584        self.in_watchdog = true;
585        self.watchdog_iterate = Some(curr);
586        self.watchdog_delta = Some(delta.clone());
587        self.watchdog_trial_iter = 0;
588        let tau = data.borrow().curr_tau;
589        self.watchdog_alpha_primal_test = cq.borrow().aff_step_alpha_primal_max(delta, tau);
590        self.watchdog_theta = cq.borrow().curr_constraint_violation();
591        self.watchdog_phi = cq.borrow().curr_barrier_obj();
592        self.watchdog_d_phi = self.compute_d_phi(cq, delta);
593    }
594
595    /// Handle alpha-loop failure while in watchdog mode. Bumps
596    /// `watchdog_trial_iter`; if the cap is exceeded, reverts to the
597    /// snapshot (StopWatchDog) and re-runs the alpha-loop on the
598    /// saved `delta` with `skip_first=true`. Otherwise accepts the
599    /// current trial as 'w' and returns. Mirrors
600    /// `IpBacktrackingLineSearch.cpp:480-503` together with
601    /// `IpBacktrackingLineSearch.cpp:871-908`'s `StopWatchDog`.
602    fn handle_watchdog_failure(
603        &mut self,
604        data: &IpoptDataHandle,
605        cq: &IpoptCqHandle,
606        alpha_init: Number,
607        alpha_dual: Number,
608        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
609        n_steps: i32,
610        last_alpha: Number,
611        evaluation_error: bool,
612    ) -> Outcome {
613        self.watchdog_trial_iter += 1;
614        // Mirror upstream `IpBacktrackingLineSearch.cpp:493`:
615        // `if (evaluation_error || watchdog_trial_iter > max)` →
616        // StopWatchDog. A non-finite trial must NOT be promoted via
617        // the 'w' accept-anyway path; doing so propagates NaN/Inf
618        // into the next outer iter and the iterate is unrecoverable
619        // (observed on PFIT3, PFIT4).
620        if evaluation_error || self.watchdog_trial_iter > self.watchdog_trial_iter_max {
621            // StopWatchDog: revert curr to the snapshot, re-run on
622            // saved delta with `skip_first=true` (alpha starts at
623            // `alpha_init * alpha_red_factor`).
624            let snapshot_iter = self.watchdog_iterate.take();
625            let snapshot_delta = self.watchdog_delta.take();
626            self.in_watchdog = false;
627            self.watchdog_shortened_iter = 0;
628            let (Some(snap), Some(snap_delta)) = (snapshot_iter, snapshot_delta) else {
629                // Defensive — this should not happen if start_watchdog
630                // ran successfully. Fall through to genuine failure.
631                let mut d = data.borrow_mut();
632                d.trial = None;
633                d.info_alpha_primal = last_alpha;
634                d.info_alpha_dual = 0.0;
635                d.info_alpha_primal_char = 'R';
636                d.info_ls_count = n_steps + 1;
637                return Outcome::Failed;
638            };
639            {
640                let mut d = data.borrow_mut();
641                d.set_curr(snap);
642            }
643            let theta = cq.borrow().curr_constraint_violation();
644            let phi = cq.borrow().curr_barrier_obj();
645            let d_phi = self.compute_d_phi(cq, &snap_delta);
646            // SOC is disabled on the StopWatchDog retry. The original
647            // `search_dir` was consumed by the first alpha-loop call
648            // and we want a plain backtracking pass over the saved
649            // delta; mirrors upstream's behavior of not running the
650            // soc_method on the recovered search.
651            let result2 = self.run_alpha_loop(
652                data,
653                cq,
654                &snap_delta,
655                alpha_init,
656                alpha_dual,
657                nlp,
658                None,
659                theta,
660                phi,
661                d_phi,
662                /*skip_first*/ true,
663            );
664            match result2 {
665                AlphaResult::Accepted { n_steps: ns2 } => {
666                    if ns2 == 0 {
667                        self.watchdog_shortened_iter = 0;
668                    } else {
669                        self.watchdog_shortened_iter += 1;
670                    }
671                    Outcome::Accepted
672                }
673                AlphaResult::TinyStep {
674                    n_steps: ns2,
675                    last_alpha: la2,
676                } => {
677                    let mut d = data.borrow_mut();
678                    d.trial = None;
679                    d.info_alpha_primal = la2;
680                    d.info_alpha_dual = 0.0;
681                    d.info_alpha_primal_char = 'R';
682                    d.info_ls_count = ns2 + 1;
683                    Outcome::TinyStep
684                }
685                AlphaResult::Failed {
686                    n_steps: ns2,
687                    last_alpha: la2,
688                    evaluation_error: _,
689                } => {
690                    let mut d = data.borrow_mut();
691                    d.trial = None;
692                    d.info_alpha_primal = la2;
693                    d.info_alpha_dual = 0.0;
694                    d.info_alpha_primal_char = 'R';
695                    d.info_ls_count = ns2 + 1;
696                    Outcome::Failed
697                }
698            }
699        } else {
700            // Accept the last attempted trial despite filter rejection
701            // — `accept-anyway` watchdog branch
702            // (`IpBacktrackingLineSearch.cpp:498-503`). The trial
703            // iterate from the final α attempt is already in
704            // `data.trial`. Crucially, we do NOT call
705            // `update_for_next_iteration`, so the filter is NOT
706            // augmented (matching upstream's char='w' branch at
707            // line 833-836 which skips `UpdateForNextIteration`).
708            let mut d = data.borrow_mut();
709            d.info_alpha_primal = last_alpha;
710            d.info_alpha_dual = alpha_dual;
711            d.info_alpha_primal_char = 'w';
712            d.info_ls_count = n_steps + 1;
713            Outcome::Accepted
714        }
715    }
716
717    /// Inner alpha-reduction loop. Tries
718    /// `alpha = alpha_init * alpha_red_factor^k` (or
719    /// `alpha_red_factor^(k+1)` when `skip_first=true`) and consults
720    /// the acceptor against the supplied reference `(theta, phi, d_phi)`.
721    /// On accept stamps the info fields and calls
722    /// `update_for_next_iteration`. On reject leaves the LAST trial in
723    /// `data.trial` so the watchdog `accept-anyway` path can promote
724    /// it.
725    #[allow(clippy::too_many_arguments)]
726    fn run_alpha_loop(
727        &mut self,
728        data: &IpoptDataHandle,
729        cq: &IpoptCqHandle,
730        delta: &IteratesVector,
731        alpha_init: Number,
732        alpha_dual: Number,
733        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
734        search_dir: Option<&mut PdSearchDirCalc>,
735        theta: Number,
736        phi: Number,
737        d_phi: Number,
738        skip_first: bool,
739    ) -> AlphaResult {
740        let curr = match data.borrow().curr.clone() {
741            Some(c) => c,
742            None => {
743                return AlphaResult::Failed {
744                    n_steps: 0,
745                    last_alpha: 0.0,
746                    evaluation_error: false,
747                }
748            }
749        };
750
751        let mut evaluation_error = false;
752
753        let mut soc_search_dir = search_dir;
754        let (mut c_soc_buf, mut dms_soc_buf) =
755            if soc_search_dir.is_some() && nlp.is_some() && self.max_soc > 0 && !skip_first {
756                let cq_ref = cq.borrow();
757                let curr_c = cq_ref.curr_c();
758                let curr_dms = cq_ref.curr_d_minus_s();
759                let mut c_soc = curr_c.make_new();
760                c_soc.copy(&*curr_c);
761                let mut dms_soc = curr_dms.make_new();
762                dms_soc.copy(&*curr_dms);
763                (Some(c_soc), Some(dms_soc))
764            } else {
765                (None, None)
766            };
767
768        let mut alpha = if skip_first {
769            alpha_init * self.alpha_red_factor
770        } else {
771            alpha_init
772        };
773        let mut last_alpha = alpha;
774        let mut n_steps: i32 = 0;
775        let acceptor_alpha_min = self.acceptor.calc_alpha_min(d_phi, theta);
776        let alpha_min_eff = self.alpha_min.max(acceptor_alpha_min);
777
778        for trial in 0..self.max_trials {
779            if alpha < alpha_min_eff {
780                return AlphaResult::TinyStep {
781                    n_steps,
782                    last_alpha,
783                };
784            }
785            last_alpha = alpha;
786            n_steps = trial;
787
788            let trial_iv = scaled_step(&curr, delta, alpha, alpha_dual);
789            data.borrow_mut().set_trial(trial_iv);
790
791            let theta_trial = cq.borrow().trial_constraint_violation();
792            let phi_trial = cq.borrow().trial_barrier_obj();
793            if !theta_trial.is_finite() || !phi_trial.is_finite() {
794                // Mirror upstream `IpBacktrackingLineSearch.cpp:776-784`:
795                // a non-finite eval is treated as `Eval_Error`, sets the
796                // `evaluation_error` flag, and the alpha-loop continues
797                // to backtrack. Under watchdog, upstream breaks out
798                // immediately (line 791-794) so the watchdog handler
799                // can force StopWatchDog via line 493.
800                evaluation_error = true;
801                if self.in_watchdog {
802                    return AlphaResult::Failed {
803                        n_steps: trial,
804                        last_alpha: alpha,
805                        evaluation_error: true,
806                    };
807                }
808                alpha *= self.alpha_red_factor;
809                continue;
810            }
811
812            let decision =
813                self.acceptor
814                    .check_trial_point(alpha, theta, phi, d_phi, theta_trial, phi_trial);
815            if decision == AcceptDecision::Accept {
816                let mode = self
817                    .acceptor
818                    .update_for_next_iteration(alpha, theta, phi, d_phi, phi_trial);
819                if std::env::var_os("POUNCE_DBG_LS").is_some() {
820                    let d = data.borrow();
821                    eprintln!(
822                        "[PN_LS] iter={} mu={:.3e} alpha={:.3e} alpha_d={:.3e} mode={} theta={:.6e} theta_trial={:.6e} phi={:.6e} phi_trial={:.6e} n_steps={}",
823                        d.iter_count, d.curr_mu, alpha, alpha_dual, mode, theta, theta_trial, phi, phi_trial, trial
824                    );
825                }
826                let mut d = data.borrow_mut();
827                d.info_alpha_primal = alpha;
828                d.info_alpha_dual = alpha_dual;
829                d.info_ls_count = trial + 1;
830                d.info_alpha_primal_char = mode;
831                return AlphaResult::Accepted { n_steps: trial };
832            }
833
834            // Watchdog: under upstream `IpBacktrackingLineSearch.cpp:791-794`,
835            // a failed trial inside the watchdog window breaks out of the
836            // alpha-loop immediately — alpha is NOT reduced. The trial just
837            // attempted (at the full `alpha_init`) is left in `data.trial`
838            // so `handle_watchdog_failure` can promote it via the 'w'
839            // accept-anyway branch. Without this break, pounce kept
840            // reducing alpha under watchdog and accepted the same tiny
841            // step that triggered watchdog activation in the first place,
842            // leaving the iterate stalled (observed on HATFLDFLNE: iter 11
843            // accepted α=1.22e-4 'h' instead of α=1.00 'w').
844            if self.in_watchdog {
845                return AlphaResult::Failed {
846                    n_steps: trial,
847                    last_alpha: alpha,
848                    evaluation_error,
849                };
850            }
851
852            // SOC: only on the first non-skipped trial when constraint
853            // violation grew. Disabled when `skip_first=true` (no SOC
854            // buffers were allocated). Also disabled under watchdog (the
855            // `in_watchdog` break above pre-empts SOC, matching upstream
856            // which gates SOC after the in_watchdog break).
857            if trial == 0
858                && !skip_first
859                && self.max_soc > 0
860                && theta <= theta_trial
861                && c_soc_buf.is_some()
862                && dms_soc_buf.is_some()
863            {
864                let alpha_test = alpha;
865                let mut count_soc: i32 = 0;
866                let mut theta_soc_old: Number = 0.0;
867                let mut theta_trial_local = theta_trial;
868                let mut alpha_primal_soc = alpha;
869                let mut soc_accepted = false;
870                while count_soc < self.max_soc
871                    && !soc_accepted
872                    && (count_soc == 0 || theta_trial_local <= self.kappa_soc * theta_soc_old)
873                {
874                    theta_soc_old = theta_trial_local;
875                    {
876                        let cq_ref = cq.borrow();
877                        let trial_c = cq_ref.trial_c();
878                        let trial_dms = cq_ref.trial_d_minus_s();
879                        if let Some(c_soc) = c_soc_buf.as_mut() {
880                            c_soc.scal(alpha_primal_soc);
881                            c_soc.axpy(1.0, &*trial_c);
882                        }
883                        if let Some(dms_soc) = dms_soc_buf.as_mut() {
884                            dms_soc.scal(alpha_primal_soc);
885                            dms_soc.axpy(1.0, &*trial_dms);
886                        }
887                    }
888                    let delta_soc_opt = {
889                        let sd = soc_search_dir
890                            .as_deref_mut()
891                            .expect("SOC: search_dir is gated above");
892                        let nlp_ref = nlp.expect("SOC: nlp is gated above");
893                        let c_soc = c_soc_buf.as_deref().expect("SOC: c_soc_buf is gated above");
894                        let dms_soc = dms_soc_buf
895                            .as_deref()
896                            .expect("SOC: dms_soc_buf is gated above");
897                        sd.compute_soc_step(
898                            data,
899                            cq,
900                            nlp_ref,
901                            c_soc,
902                            dms_soc,
903                            alpha_primal_soc,
904                            self.soc_method,
905                        )
906                    };
907                    let Some(delta_soc) = delta_soc_opt else {
908                        break;
909                    };
910                    let tau = data.borrow().curr_tau;
911                    alpha_primal_soc = cq.borrow().aff_step_alpha_primal_max(&delta_soc, tau);
912                    // Upstream `IpFilterLSAcceptor.cpp` sets `actual_delta =
913                    // delta_soc` on an accepted SOC step: the *entire* step,
914                    // primal and dual, is replaced. The dual update therefore
915                    // uses the SOC step's own multiplier components — not the
916                    // original `delta` — and the dual fraction-to-boundary is
917                    // recomputed from `delta_soc`
918                    // (`IpBacktrackingLineSearch.cpp:639`). Applying `delta`'s
919                    // duals here left the accepted iterate with a primal from
920                    // `delta_soc` but duals from `delta`, diverging `inf_du`
921                    // from Ipopt on any `H`-flagged iteration (e.g. CRESC4).
922                    let alpha_dual_soc = cq.borrow().aff_step_alpha_dual_max(&delta_soc, tau);
923                    let mut trial_iv = curr.deep_copy();
924                    trial_iv.x.axpy(alpha_primal_soc, &*delta_soc.x);
925                    trial_iv.s.axpy(alpha_primal_soc, &*delta_soc.s);
926                    trial_iv.y_c.axpy(alpha_primal_soc, &*delta_soc.y_c);
927                    trial_iv.y_d.axpy(alpha_primal_soc, &*delta_soc.y_d);
928                    trial_iv.z_l.axpy(alpha_dual_soc, &*delta_soc.z_l);
929                    trial_iv.z_u.axpy(alpha_dual_soc, &*delta_soc.z_u);
930                    trial_iv.v_l.axpy(alpha_dual_soc, &*delta_soc.v_l);
931                    trial_iv.v_u.axpy(alpha_dual_soc, &*delta_soc.v_u);
932                    let trial_iv = trial_iv.freeze();
933                    data.borrow_mut().set_trial(trial_iv);
934                    let theta_soc = cq.borrow().trial_constraint_violation();
935                    let phi_soc = cq.borrow().trial_barrier_obj();
936                    if !theta_soc.is_finite() || !phi_soc.is_finite() {
937                        break;
938                    }
939                    let dec = self
940                        .acceptor
941                        .check_trial_point(alpha_test, theta, phi, d_phi, theta_soc, phi_soc);
942                    if dec == AcceptDecision::Accept {
943                        let mode = self
944                            .acceptor
945                            .update_for_next_iteration(alpha_test, theta, phi, d_phi, phi_soc);
946                        let mut d = data.borrow_mut();
947                        d.info_alpha_primal = alpha_primal_soc;
948                        d.info_alpha_dual = alpha_dual_soc;
949                        d.info_ls_count = trial + 1;
950                        d.info_alpha_primal_char = mode.to_ascii_uppercase();
951                        return AlphaResult::Accepted { n_steps: trial };
952                    }
953                    count_soc += 1;
954                    theta_trial_local = theta_soc;
955                    soc_accepted = false;
956                }
957            }
958
959            alpha *= self.alpha_red_factor;
960        }
961
962        AlphaResult::Failed {
963            n_steps,
964            last_alpha,
965            evaluation_error,
966        }
967    }
968
969    /// Directional derivative of the barrier objective along the step
970    /// `delta`: `d_phi = ∇_x φ · dx + ∇_s φ · ds`.
971    fn compute_d_phi(&self, cq: &IpoptCqHandle, delta: &IteratesVector) -> Number {
972        let cq_ref = cq.borrow();
973        let g_x = cq_ref.curr_grad_barrier_obj_x();
974        let g_s = cq_ref.curr_grad_barrier_obj_s();
975        g_x.dot(&*delta.x) + g_s.dot(&*delta.s)
976    }
977}
978
979/// `out = curr + alpha * delta` for all eight components, returned as a
980/// fresh `IteratesVector` with `Rc<dyn Vector>` slots. Mirrors
981/// `IpoptData::SetTrialBoundMultipliersFromStep` + the primal step
982/// path in upstream — both share the same scalar α here because
983/// fraction-to-the-boundary truncation has already been folded into
984/// `alpha_init` upstream.
985fn scaled_step(
986    curr: &IteratesVector,
987    delta: &IteratesVector,
988    alpha_primal: Number,
989    alpha_dual: Number,
990) -> IteratesVector {
991    let mut out = curr.make_new_zeroed();
992    out.add_one_vector(1.0, curr, 0.0); // out = curr
993    out.x.axpy(alpha_primal, &*delta.x);
994    out.s.axpy(alpha_primal, &*delta.s);
995    out.y_c.axpy(alpha_primal, &*delta.y_c);
996    out.y_d.axpy(alpha_primal, &*delta.y_d);
997    out.z_l.axpy(alpha_dual, &*delta.z_l);
998    out.z_u.axpy(alpha_dual, &*delta.z_u);
999    out.v_l.axpy(alpha_dual, &*delta.v_l);
1000    out.v_u.axpy(alpha_dual, &*delta.v_u);
1001    out.freeze()
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::iterates_vector::IteratesVector;
1008    use crate::line_search::filter_acceptor::FilterLsAcceptor;
1009    use pounce_linalg::dense_vector::DenseVectorSpace;
1010    use pounce_linalg::Vector;
1011    use std::rc::Rc;
1012
1013    fn dense(n: i32, vals: &[Number]) -> Rc<dyn Vector> {
1014        let mut v = DenseVectorSpace::new(n).make_new_dense();
1015        v.set(0.0);
1016        if !vals.is_empty() {
1017            v.values_mut().copy_from_slice(vals);
1018        }
1019        Rc::new(v)
1020    }
1021
1022    fn iv_from(x: &[Number], s: &[Number]) -> IteratesVector {
1023        IteratesVector::new(
1024            dense(x.len() as i32, x),
1025            dense(s.len() as i32, s),
1026            dense(0, &[]),
1027            dense(0, &[]),
1028            dense(0, &[]),
1029            dense(0, &[]),
1030            dense(0, &[]),
1031            dense(0, &[]),
1032        )
1033    }
1034
1035    #[test]
1036    fn driver_constructs_with_defaults() {
1037        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1038        assert_eq!(bls.alpha_red_factor, 0.5);
1039        assert_eq!(bls.max_soc, 4);
1040    }
1041
1042    #[test]
1043    fn scaled_step_writes_curr_plus_alpha_delta() {
1044        // curr.x = (0,0), delta.x = (1,1) → at alpha=0.5, trial.x = (0.5, 0.5).
1045        let curr = iv_from(&[0.0, 0.0], &[0.0]);
1046        let delta = iv_from(&[1.0, 1.0], &[2.0]);
1047        let trial = scaled_step(&curr, &delta, 0.5, 0.5);
1048        let xv = trial
1049            .x
1050            .as_any()
1051            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1052            .unwrap()
1053            .values()
1054            .to_vec();
1055        assert_eq!(xv, vec![0.5, 0.5]);
1056        let sv = trial
1057            .s
1058            .as_any()
1059            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1060            .unwrap()
1061            .values()
1062            .to_vec();
1063        assert_eq!(sv, vec![1.0]); // 0.0 + 0.5 * 2.0
1064    }
1065
1066    #[test]
1067    fn outcome_variants_are_distinct() {
1068        assert_ne!(Outcome::Accepted, Outcome::Failed);
1069        assert_ne!(Outcome::Accepted, Outcome::TinyStep);
1070        assert_ne!(Outcome::Failed, Outcome::TinyStep);
1071    }
1072
1073    #[test]
1074    fn watchdog_state_starts_inactive() {
1075        // Mirror upstream `IpBacktrackingLineSearch::InitializeImpl`
1076        // (`IpBacktrackingLineSearch.cpp:240-249`): the watchdog is
1077        // inactive at construction and `last_mu_` is initialised to
1078        // a sentinel `-1` so the first iteration's mu always
1079        // triggers the reset branch (which is harmless when the
1080        // watchdog was never armed).
1081        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1082        assert!(!bls.in_watchdog());
1083        assert_eq!(bls.watchdog_shortened_iter(), 0);
1084        assert!(bls.last_mu < 0.0);
1085        assert_eq!(bls.watchdog_shortened_iter_trigger, 10);
1086        assert_eq!(bls.watchdog_trial_iter_max, 3);
1087    }
1088
1089    #[test]
1090    fn alpha_result_failed_carries_n_steps_and_last_alpha() {
1091        // Sanity check on the internal AlphaResult enum: the watchdog
1092        // wrapper relies on `Failed { n_steps, last_alpha }` to stamp
1093        // the info-* fields when handing off to restoration.
1094        let r = AlphaResult::Failed {
1095            n_steps: 7,
1096            last_alpha: 1e-6,
1097            evaluation_error: false,
1098        };
1099        match r {
1100            AlphaResult::Failed {
1101                n_steps,
1102                last_alpha,
1103                evaluation_error,
1104            } => {
1105                assert_eq!(n_steps, 7);
1106                assert!((last_alpha - 1e-6).abs() < 1e-20);
1107                assert!(!evaluation_error);
1108            }
1109            _ => unreachable!(),
1110        }
1111    }
1112}