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
48/// Policy for the step length applied to the equality multipliers
49/// `y_c`, `y_d`. Mirrors upstream's `alpha_for_y` option (subset of
50/// the upstream enum — pounce only ports the variants that the
51/// Mehrotra cascade and default code paths exercise).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AlphaForY {
54    /// Use the primal step length (upstream default).
55    Primal,
56    /// Use the dual step length. Selected by the Mehrotra cascade
57    /// (`alpha_for_y=bound_mult`).
58    BoundMult,
59    /// Always take a full step on the equality multipliers.
60    Full,
61    /// Use the minimum of the primal and dual step lengths.
62    Min,
63    /// Use the maximum of the primal and dual step lengths.
64    Max,
65    /// Use the arithmetic mean of the primal and dual step lengths.
66    Average,
67}
68
69impl AlphaForY {
70    /// Compute the actual step length for `y_c`, `y_d` given the
71    /// already-selected primal and dual step lengths.
72    pub fn alpha_y(self, alpha_primal: Number, alpha_dual: Number) -> Number {
73        match self {
74            AlphaForY::Primal => alpha_primal,
75            AlphaForY::BoundMult => alpha_dual,
76            AlphaForY::Full => 1.0,
77            AlphaForY::Min => alpha_primal.min(alpha_dual),
78            AlphaForY::Max => alpha_primal.max(alpha_dual),
79            AlphaForY::Average => 0.5 * (alpha_primal + alpha_dual),
80        }
81    }
82}
83
84pub struct BacktrackingLineSearch {
85    pub acceptor: Box<dyn BacktrackingLsAcceptor>,
86    pub alpha_red_factor: Number,
87    pub max_soc: i32,
88    /// Threshold for the SOC outer-loop convergence test
89    /// `theta_trial <= kappa_soc * theta_soc_old`. Mirrors upstream's
90    /// `kappa_soc` (default 0.99).
91    pub kappa_soc: Number,
92    /// SOC RHS variant. `0` = upstream default ("old"), `1` = scaled
93    /// gradient-block variant. Both correspond to upstream's
94    /// `soc_method` option.
95    pub soc_method: i32,
96    /// Number of consecutive shortened iterations before the watchdog
97    /// procedure activates. Disabled when `<= 0`. Mirrors upstream's
98    /// `watchdog_shortened_iter_trigger` (default 10).
99    pub watchdog_shortened_iter_trigger: i32,
100    /// Maximum number of outer iterations the watchdog will accept
101    /// non-decreasing trial points before reverting to the snapshot.
102    /// Mirrors upstream's `watchdog_trial_iter_max` (default 3).
103    pub watchdog_trial_iter_max: i32,
104    /// Lower bound on α; below this we declare a tiny step (mirrors
105    /// `alpha_min_frac` flow, `IpBacktrackingLineSearch.cpp:CalculateAlphaMin`).
106    pub alpha_min: Number,
107    /// Maximum trial-iteration cap before declaring failure.
108    pub max_trials: i32,
109
110    // ---- Watchdog state (port of `IpBacktrackingLineSearch.{hpp,cpp}`'s
111    //      `in_watchdog_`, `watchdog_iterate_`, `watchdog_delta_`,
112    //      `watchdog_alpha_primal_test_`, `watchdog_trial_iter_`,
113    //      `watchdog_shortened_iter_`, `last_mu_`).
114    //
115    // Watchdog mechanism: after `watchdog_shortened_iter_trigger`
116    // consecutive shortened (n_steps > 0) accepts, we snapshot the
117    // current iterate `(curr, delta, theta, phi, d_phi)` and enter
118    // watchdog mode. While in watchdog: the acceptor's reference
119    // values are FROZEN to the snapshot for up to
120    // `watchdog_trial_iter_max` outer iterations. Each iteration's
121    // alpha-loop runs against the frozen reference; if it accepts,
122    // watchdog terminates with success ("W"). If it rejects, we
123    // accept the last trial anyway (info char 'w') and let the next
124    // outer iteration try again. If `watchdog_trial_iter_max` outer
125    // iterations all reject, we revert to the snapshot and re-run
126    // the alpha-loop on the saved `delta` with `skip_first=true`.
127    /// True iff currently inside a watchdog window.
128    in_watchdog: bool,
129    /// Snapshot of the iterate at watchdog activation.
130    watchdog_iterate: Option<IteratesVector>,
131    /// Snapshot of the search direction at watchdog activation.
132    watchdog_delta: Option<IteratesVector>,
133    /// Number of outer iterations elapsed since watchdog activation.
134    watchdog_trial_iter: i32,
135    /// Number of consecutive shortened (n_steps > 0) accepts.
136    /// Reset on a full step (n_steps == 0), on mu change, on watchdog
137    /// success, and on watchdog stop-with-revert.
138    watchdog_shortened_iter: i32,
139    /// `mu` at the previous outer iteration. A change clears the
140    /// watchdog state (`IpBacktrackingLineSearch.cpp:259-270`).
141    last_mu: Number,
142    /// Frozen reference theta at watchdog activation.
143    watchdog_theta: Number,
144    /// Frozen reference phi at watchdog activation.
145    watchdog_phi: Number,
146    /// Frozen reference d_phi at watchdog activation.
147    watchdog_d_phi: Number,
148
149    // ---- Soft restoration phase (port of `IpBacktrackingLineSearch`'s
150    //      `in_soft_resto_phase_`, `soft_resto_counter_`).
151    //
152    // When the regular filter line search fails, before handing off to
153    // the full (sub-NLP) restoration phase, the driver tries a single
154    // damped primal-dual step along the *same* search direction. The
155    // step is damped only by the fraction-to-the-boundary rule and is
156    // accepted if it either satisfies the original filter criterion
157    // ('S' — leave soft resto) or merely reduces the primal-dual KKT
158    // system error by `soft_resto_pderror_reduction_factor` ('s' —
159    // stay in soft resto). Subsequent outer iterations keep taking
160    // soft-resto steps until the original criterion is met, the step
161    // is rejected, or `max_soft_resto_iters` consecutive iterations
162    // elapse — any of which drops through to full restoration.
163    /// Required relative reduction in the primal-dual system error for
164    /// a soft-resto step to be accepted. `0` disables soft restoration.
165    /// Mirrors upstream `soft_resto_pderror_reduction_factor`
166    /// (default `1 - 1e-4`).
167    pub soft_resto_pderror_reduction_factor: Number,
168    /// Cap on consecutive soft-resto iterations before full
169    /// restoration is forced. Mirrors upstream `max_soft_resto_iters`
170    /// (default 10).
171    pub max_soft_resto_iters: i32,
172    /// True iff the driver is currently inside the soft-resto phase.
173    in_soft_resto_phase: bool,
174    /// Count of consecutive soft-resto iterations taken so far.
175    soft_resto_counter: i32,
176
177    /// `accept_every_trial_step` — when true, the alpha loop and filter
178    /// are bypassed: the FTB-truncated `alpha_init`/`alpha_dual` step
179    /// is set as the trial and accepted unconditionally. Mirrors
180    /// upstream's `IpBacktrackingLineSearch.cpp:accept_every_trial_step_`
181    /// short-circuit at the top of `FindAcceptableTrialPoint`.
182    pub accept_every_trial_step: bool,
183    /// `alpha_for_y` policy applied to the equality multipliers `y_c`,
184    /// `y_d` when constructing the trial iterate. See [`AlphaForY`].
185    pub alpha_for_y: AlphaForY,
186}
187
188/// Internal alpha-loop outcome. The watchdog wrapper translates this
189/// into the public [`Outcome`] after applying its state machine.
190enum AlphaResult {
191    /// Trial accepted at `alpha_used` after `n_steps` reductions.
192    Accepted { n_steps: i32 },
193    /// α dropped below `alpha_min_eff` ⇒ tiny step. `last_alpha` is
194    /// the smallest α actually evaluated; `n_steps` is the number of
195    /// reductions performed.
196    TinyStep { n_steps: i32, last_alpha: Number },
197    /// `max_trials` exhausted without acceptance. The last attempted
198    /// trial iterate is left in `data.trial` so the watchdog
199    /// "accept-anyway" path can promote it.
200    ///
201    /// `evaluation_error` flags that the last attempted trial produced
202    /// a non-finite `theta_trial`/`phi_trial` — mirrors upstream's
203    /// `evaluation_error` tracked from `IpoptNLP::Eval_Error`
204    /// (`IpBacktrackingLineSearch.cpp:776-784`). The watchdog handler
205    /// must treat this as a forced StopWatchDog
206    /// (`IpBacktrackingLineSearch.cpp:493`) — accepting a non-finite
207    /// iterate via the 'w' branch propagates NaN/Inf into the next
208    /// outer iter (observed on PFIT3 iter 53: inf_pr=7.87e305 from a
209    /// 'w'-accepted trial; on PFIT4 iter 31: inf_pr=1.01e11).
210    Failed {
211        n_steps: i32,
212        last_alpha: Number,
213        evaluation_error: bool,
214    },
215}
216
217impl BacktrackingLineSearch {
218    pub fn new(acceptor: Box<dyn BacktrackingLsAcceptor>) -> Self {
219        Self {
220            acceptor,
221            alpha_red_factor: 0.5,
222            max_soc: 4,
223            kappa_soc: 0.99,
224            soc_method: 0,
225            watchdog_shortened_iter_trigger: 10,
226            watchdog_trial_iter_max: 3,
227            alpha_min: 1e-12,
228            max_trials: 50,
229            in_watchdog: false,
230            watchdog_iterate: None,
231            watchdog_delta: None,
232            watchdog_trial_iter: 0,
233            watchdog_shortened_iter: 0,
234            last_mu: -1.0,
235            watchdog_theta: 0.0,
236            watchdog_phi: 0.0,
237            watchdog_d_phi: 0.0,
238            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
239            max_soft_resto_iters: 10,
240            in_soft_resto_phase: false,
241            soft_resto_counter: 0,
242            accept_every_trial_step: false,
243            alpha_for_y: AlphaForY::Primal,
244        }
245    }
246
247    /// Test-only accessor for the watchdog active flag.
248    #[cfg(test)]
249    pub(crate) fn in_watchdog(&self) -> bool {
250        self.in_watchdog
251    }
252
253    /// Test-only accessor for the shortened-iter counter.
254    #[cfg(test)]
255    pub(crate) fn watchdog_shortened_iter(&self) -> i32 {
256        self.watchdog_shortened_iter
257    }
258
259    pub fn acceptor(&self) -> &dyn BacktrackingLsAcceptor {
260        &*self.acceptor
261    }
262
263    pub fn acceptor_mut(&mut self) -> &mut dyn BacktrackingLsAcceptor {
264        &mut *self.acceptor
265    }
266
267    /// Reset the acceptor state at the start of a new outer iteration.
268    pub fn reset(&mut self) {
269        self.acceptor.reset();
270    }
271
272    /// Public line-search entry point. Wraps the regular filter line
273    /// search ([`Self::run_filter_line_search`]) with the soft
274    /// restoration phase — port of the `in_soft_resto_phase_` state
275    /// machine in `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
276    /// (`IpBacktrackingLineSearch.cpp:439-465` for the in-phase
277    /// continuation, `:528-556` for entering the phase).
278    ///
279    /// Outcomes:
280    /// - `Accepted`: a trial point is in `data.trial` — either a
281    ///   regular filter/watchdog step or a soft-resto step (info char
282    ///   's' = stay in soft resto, 'S' = step also satisfies the
283    ///   original filter so soft resto is left).
284    /// - `TinyStep` / `Failed`: neither the regular line search nor a
285    ///   soft-resto step could make progress; the caller hands off to
286    ///   the full restoration phase.
287    #[allow(clippy::too_many_arguments)]
288    pub fn find_acceptable_trial_point(
289        &mut self,
290        data: &IpoptDataHandle,
291        cq: &IpoptCqHandle,
292        delta: &IteratesVector,
293        alpha_init: Number,
294        alpha_dual: Number,
295        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
296        search_dir: Option<&mut PdSearchDirCalc>,
297    ) -> Outcome {
298        // ---- `accept_every_trial_step` short-circuit. Mirrors the
299        // unglobalized path at the top of
300        // `IpBacktrackingLineSearch::FindAcceptableTrialPoint` (when
301        // `accept_every_trial_step_` is true): no soft-resto, no
302        // watchdog, no alpha loop, no filter update — just take the
303        // FTB-truncated step (`alpha_init`, `alpha_dual` already
304        // include the fraction-to-the-boundary rule) and accept it
305        // unconditionally. Used by the Mehrotra cascade.
306        if self.accept_every_trial_step {
307            let curr = match data.borrow().curr.clone() {
308                Some(c) => c,
309                None => return Outcome::Failed,
310            };
311            let alpha_y = self.alpha_for_y.alpha_y(alpha_init, alpha_dual);
312            let trial_iv = scaled_step(&curr, delta, alpha_init, alpha_y, alpha_dual);
313            let mut d = data.borrow_mut();
314            d.set_trial(trial_iv);
315            d.info_alpha_primal = alpha_init;
316            d.info_alpha_dual = alpha_dual;
317            d.info_alpha_primal_char = ' ';
318            d.info_ls_count = 1;
319            return Outcome::Accepted;
320        }
321
322        // ---- Soft-resto continuation. Already inside the phase: bump
323        // the counter, bail to full restoration once it exceeds
324        // `max_soft_resto_iters`, otherwise take another damped
325        // primal-dual step along the caller's `delta`
326        // (`IpBacktrackingLineSearch.cpp:439-465`).
327        if self.in_soft_resto_phase {
328            self.soft_resto_counter += 1;
329            if self.soft_resto_counter > self.max_soft_resto_iters {
330                self.in_soft_resto_phase = false;
331                self.soft_resto_counter = 0;
332                return self.fail_to_restoration(data);
333            }
334            // Per-outer-iteration acceptor hook (no-op for the filter
335            // acceptor; the penalty acceptor caches its reference here).
336            self.acceptor.init_this_line_search(data, cq, delta);
337            return match self.try_soft_resto_step(data, cq, delta) {
338                Some(satisfies_original) => {
339                    if satisfies_original {
340                        self.in_soft_resto_phase = false;
341                        self.soft_resto_counter = 0;
342                        data.borrow_mut().info_alpha_primal_char = 'S';
343                    } else {
344                        data.borrow_mut().info_alpha_primal_char = 's';
345                    }
346                    Outcome::Accepted
347                }
348                None => {
349                    self.in_soft_resto_phase = false;
350                    self.soft_resto_counter = 0;
351                    self.fail_to_restoration(data)
352                }
353            };
354        }
355
356        // ---- Regular filter line search (watchdog + alpha loop).
357        let outcome =
358            self.run_filter_line_search(data, cq, delta, alpha_init, alpha_dual, nlp, search_dir);
359        if outcome == Outcome::Accepted {
360            return Outcome::Accepted;
361        }
362
363        // ---- Regular line search failed. Before the (expensive) full
364        // restoration sub-NLP, try to *enter* the soft restoration
365        // phase with one damped primal-dual step
366        // (`IpBacktrackingLineSearch.cpp:528-556`). `prepare_resto_phase_start`
367        // augments the outer filter with the entry envelope — mirrors
368        // upstream's `acceptor_->PrepareRestoPhaseStart()` at line 537.
369        let reference_theta = cq.borrow().curr_constraint_violation();
370        let reference_barr = cq.borrow().curr_barrier_obj();
371        self.acceptor
372            .prepare_resto_phase_start(reference_theta, reference_barr);
373        match self.try_soft_resto_step(data, cq, delta) {
374            Some(satisfies_original) => {
375                if satisfies_original {
376                    data.borrow_mut().info_alpha_primal_char = 'S';
377                } else {
378                    self.in_soft_resto_phase = true;
379                    self.soft_resto_counter = 0;
380                    data.borrow_mut().info_alpha_primal_char = 's';
381                }
382                Outcome::Accepted
383            }
384            // Soft resto could not help — fall through to full
385            // restoration with the original failure outcome. The
386            // caller's `invoke_restoration` re-runs
387            // `prepare_resto_phase_start`; the duplicate filter
388            // augmentation is idempotent (same envelope).
389            None => outcome,
390        }
391    }
392
393    /// Stamp the info fields for a hand-off to the full restoration
394    /// phase and return `Outcome::Failed`. Used when the soft
395    /// restoration phase exhausts its iteration budget or its step is
396    /// rejected mid-phase.
397    fn fail_to_restoration(&self, data: &IpoptDataHandle) -> Outcome {
398        let mut d = data.borrow_mut();
399        d.trial = None;
400        d.info_alpha_primal = 0.0;
401        d.info_alpha_dual = 0.0;
402        d.info_alpha_primal_char = 'R';
403        d.info_ls_count = 0;
404        Outcome::Failed
405    }
406
407    /// Attempt a single damped primal-dual step for the soft
408    /// restoration phase — port of
409    /// `BacktrackingLineSearch::TrySoftRestoStep`
410    /// (`IpBacktrackingLineSearch.cpp:1112-1217`). The step along
411    /// `delta` is damped only by the fraction-to-the-boundary rule,
412    /// with an identical step length for primal and dual variables.
413    ///
414    /// Returns:
415    /// - `Some(true)`  — trial accepted *and* it satisfies the
416    ///   original filter criterion ⇒ caller leaves soft resto ('S').
417    /// - `Some(false)` — trial accepted only on the primal-dual error
418    ///   reduction test ⇒ caller stays in soft resto ('s').
419    /// - `None`        — trial rejected (or soft resto disabled / a
420    ///   non-finite evaluation) ⇒ caller falls through to the full
421    ///   restoration phase.
422    ///
423    /// On a `Some(_)` return the accepted trial is left in `data.trial`
424    /// and the numeric `info_*` fields are stamped; the caller stamps
425    /// `info_alpha_primal_char`.
426    fn try_soft_resto_step(
427        &mut self,
428        data: &IpoptDataHandle,
429        cq: &IpoptCqHandle,
430        delta: &IteratesVector,
431    ) -> Option<bool> {
432        // Soft restoration is disabled when the reduction factor is
433        // zero (`IpBacktrackingLineSearch.cpp:1124`).
434        if self.soft_resto_pderror_reduction_factor == 0.0 {
435            return None;
436        }
437        let curr = data.borrow().curr.clone()?;
438        let tau = data.borrow().curr_tau;
439
440        // Identical step length for primal and dual variables, damped
441        // only by the fraction-to-the-boundary rule
442        // (`IpBacktrackingLineSearch.cpp:1135-1140`).
443        let alpha = {
444            let cq_ref = cq.borrow();
445            cq_ref
446                .aff_step_alpha_primal_max(delta, tau)
447                .min(cq_ref.aff_step_alpha_dual_max(delta, tau))
448        };
449
450        // Soft-resto uses the same scalar α for primal, equality
451        // multipliers, and bound multipliers (per upstream).
452        let trial_iv = scaled_step(&curr, delta, alpha, alpha, alpha);
453        data.borrow_mut().set_trial(trial_iv);
454
455        let theta_trial = cq.borrow().trial_constraint_violation();
456        let phi_trial = cq.borrow().trial_barrier_obj();
457        if !theta_trial.is_finite() || !phi_trial.is_finite() {
458            // Upstream retries up to three times on `Eval_Error`; the
459            // step length is fixed, so a non-finite eval here is
460            // deterministic — treat it as a rejection.
461            return None;
462        }
463
464        let theta = cq.borrow().curr_constraint_violation();
465        let phi = cq.borrow().curr_barrier_obj();
466        let d_phi = self.compute_d_phi(cq, delta);
467
468        // First test: is the trial acceptable to the *original*
469        // backtracking globalization? Upstream
470        // `acceptor_->CheckAcceptabilityOfTrialPoint(0.)`.
471        if self
472            .acceptor
473            .check_trial_point(0.0, theta, phi, d_phi, theta_trial, phi_trial)
474            == AcceptDecision::Accept
475        {
476            let mut d = data.borrow_mut();
477            d.info_alpha_primal = alpha;
478            d.info_alpha_dual = alpha;
479            d.info_ls_count = 1;
480            return Some(true);
481        }
482
483        // Second test: sufficient reduction in the primal-dual KKT
484        // system error (`IpBacktrackingLineSearch.cpp:1184-1211`).
485        let mu = data.borrow().curr_mu;
486        let curr_pderror = cq.borrow().curr_primal_dual_system_error(mu);
487        let trial_pderror = cq.borrow().trial_primal_dual_system_error(mu);
488        if !trial_pderror.is_finite() {
489            return None;
490        }
491        if trial_pderror <= self.soft_resto_pderror_reduction_factor * curr_pderror {
492            let mut d = data.borrow_mut();
493            d.info_alpha_primal = alpha;
494            d.info_alpha_dual = alpha;
495            d.info_ls_count = 1;
496            return Some(false);
497        }
498        None
499    }
500
501    /// Drive the watchdog state machine + alpha-reduction loop.
502    /// Port of `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
503    /// (`IpBacktrackingLineSearch.cpp:252-677`) restricted to the
504    /// regular (non-soft-resto) filter-acceptor, exact-Hessian path.
505    /// The soft restoration phase is layered on top by
506    /// [`Self::find_acceptable_trial_point`].
507    ///
508    /// Outcomes:
509    /// - `Accepted`: a trial point is in `data.trial`, info fields are
510    ///   stamped. The watchdog state has been advanced (success → "W",
511    ///   `accept-anyway` → 'w').
512    /// - `TinyStep`: α dropped below the dynamic alpha-min before any
513    ///   trial was accepted. Caller hands off to restoration.
514    /// - `Failed`: alpha-loop exhausted AND watchdog could not rescue.
515    ///   Caller hands off to restoration.
516    #[allow(clippy::too_many_arguments)]
517    fn run_filter_line_search(
518        &mut self,
519        data: &IpoptDataHandle,
520        cq: &IpoptCqHandle,
521        delta: &IteratesVector,
522        alpha_init: Number,
523        alpha_dual: Number,
524        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
525        search_dir: Option<&mut PdSearchDirCalc>,
526    ) -> Outcome {
527        // ---- Watchdog: detect mu change → reset state.
528        // Mirrors `IpBacktrackingLineSearch.cpp:259-270`.
529        let curr_mu = data.borrow().curr_mu;
530        if self.last_mu < 0.0 || self.last_mu != curr_mu {
531            self.in_watchdog = false;
532            self.watchdog_iterate = None;
533            self.watchdog_delta = None;
534            self.watchdog_shortened_iter = 0;
535            self.last_mu = curr_mu;
536        }
537
538        // ---- Watchdog: maybe wake up.
539        // Mirrors `IpBacktrackingLineSearch.cpp:376-380`.
540        if !self.in_watchdog
541            && self.watchdog_shortened_iter_trigger > 0
542            && self.watchdog_shortened_iter >= self.watchdog_shortened_iter_trigger
543        {
544            self.start_watchdog(data, cq, delta);
545        }
546
547        // Per-outer-iteration acceptor hook.
548        self.acceptor.init_this_line_search(data, cq, delta);
549
550        // Decide reference (theta, phi, d_phi). Mirrors upstream's
551        // `FilterLSAcceptor::InitThisLineSearch(in_watchdog)` choice
552        // between `curr_*` and the saved `watchdog_*` snapshot.
553        let (theta, phi, d_phi) = if self.in_watchdog {
554            (self.watchdog_theta, self.watchdog_phi, self.watchdog_d_phi)
555        } else {
556            let theta = cq.borrow().curr_constraint_violation();
557            let phi = cq.borrow().curr_barrier_obj();
558            let d_phi = self.compute_d_phi(cq, delta);
559            (theta, phi, d_phi)
560        };
561
562        // Run the alpha-loop on the caller's `delta`.
563        let result = self.run_alpha_loop(
564            data, cq, delta, alpha_init, alpha_dual, nlp, search_dir, theta, phi, d_phi,
565            /*skip_first*/ false,
566        );
567
568        match result {
569            AlphaResult::Accepted { n_steps } => {
570                // Update the shortened-iter counter
571                // (`IpBacktrackingLineSearch.cpp:644-655`).
572                if n_steps == 0 {
573                    self.watchdog_shortened_iter = 0;
574                } else {
575                    self.watchdog_shortened_iter += 1;
576                }
577                if self.in_watchdog {
578                    // Watchdog success — clear state, info char already
579                    // stamped by the alpha loop's
580                    // `update_for_next_iteration` call. Upstream also
581                    // appends "W" to the info string here; pounce
582                    // doesn't track an info string yet.
583                    self.in_watchdog = false;
584                    self.watchdog_iterate = None;
585                    self.watchdog_delta = None;
586                    self.watchdog_shortened_iter = 0;
587                }
588                Outcome::Accepted
589            }
590            AlphaResult::TinyStep {
591                n_steps,
592                last_alpha,
593            } => {
594                let mut d = data.borrow_mut();
595                d.trial = None;
596                d.info_alpha_primal = last_alpha;
597                d.info_alpha_dual = 0.0;
598                d.info_alpha_primal_char = 'R';
599                d.info_ls_count = n_steps + 1;
600                Outcome::TinyStep
601            }
602            AlphaResult::Failed {
603                n_steps,
604                last_alpha,
605                evaluation_error,
606            } => {
607                if self.in_watchdog {
608                    self.handle_watchdog_failure(
609                        data,
610                        cq,
611                        alpha_dual,
612                        nlp,
613                        n_steps,
614                        last_alpha,
615                        evaluation_error,
616                    )
617                } else {
618                    // Genuine failure → restoration.
619                    let mut d = data.borrow_mut();
620                    d.trial = None;
621                    d.info_alpha_primal = last_alpha;
622                    d.info_alpha_dual = 0.0;
623                    d.info_alpha_primal_char = 'R';
624                    d.info_ls_count = n_steps + 1;
625                    Outcome::Failed
626                }
627            }
628        }
629    }
630
631    /// Snapshot the current `(curr, delta, theta, phi, d_phi)` and
632    /// activate the watchdog. Mirrors upstream
633    /// `IpBacktrackingLineSearch::StartWatchDog`
634    /// (`IpBacktrackingLineSearch.cpp:855-869`) plus
635    /// `IpFilterLSAcceptor::StartWatchDog`
636    /// (`IpFilterLSAcceptor.cpp:506-513`) — pounce stores the
637    /// frozen reference values directly on the driver because the
638    /// acceptor is stateless w.r.t. reference values (the driver
639    /// passes them per call).
640    fn start_watchdog(
641        &mut self,
642        data: &IpoptDataHandle,
643        cq: &IpoptCqHandle,
644        delta: &IteratesVector,
645    ) {
646        let curr = data.borrow().curr.clone();
647        let Some(curr) = curr else {
648            return;
649        };
650        self.in_watchdog = true;
651        self.watchdog_iterate = Some(curr);
652        self.watchdog_delta = Some(delta.clone());
653        self.watchdog_trial_iter = 0;
654        self.watchdog_theta = cq.borrow().curr_constraint_violation();
655        self.watchdog_phi = cq.borrow().curr_barrier_obj();
656        self.watchdog_d_phi = self.compute_d_phi(cq, delta);
657    }
658
659    /// Handle alpha-loop failure while in watchdog mode. Bumps
660    /// `watchdog_trial_iter`; if the cap is exceeded, reverts to the
661    /// snapshot (StopWatchDog) and re-runs the alpha-loop on the
662    /// saved `delta` with `skip_first=true`. Otherwise accepts the
663    /// current trial as 'w' and returns. Mirrors
664    /// `IpBacktrackingLineSearch.cpp:480-503` together with
665    /// `IpBacktrackingLineSearch.cpp:871-908`'s `StopWatchDog`.
666    fn handle_watchdog_failure(
667        &mut self,
668        data: &IpoptDataHandle,
669        cq: &IpoptCqHandle,
670        alpha_dual: Number,
671        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
672        n_steps: i32,
673        last_alpha: Number,
674        evaluation_error: bool,
675    ) -> Outcome {
676        self.watchdog_trial_iter += 1;
677        // Mirror upstream `IpBacktrackingLineSearch.cpp:493`:
678        // `if (evaluation_error || watchdog_trial_iter > max)` →
679        // StopWatchDog. A non-finite trial must NOT be promoted via
680        // the 'w' accept-anyway path; doing so propagates NaN/Inf
681        // into the next outer iter and the iterate is unrecoverable
682        // (observed on PFIT3, PFIT4).
683        if evaluation_error || self.watchdog_trial_iter > self.watchdog_trial_iter_max {
684            // StopWatchDog: revert curr to the snapshot, re-run on
685            // saved delta with `skip_first=true` (alpha starts at
686            // `alpha_init * alpha_red_factor`).
687            let snapshot_iter = self.watchdog_iterate.take();
688            let snapshot_delta = self.watchdog_delta.take();
689            self.in_watchdog = false;
690            self.watchdog_shortened_iter = 0;
691            let (Some(snap), Some(snap_delta)) = (snapshot_iter, snapshot_delta) else {
692                // Defensive — this should not happen if start_watchdog
693                // ran successfully. Fall through to genuine failure.
694                let mut d = data.borrow_mut();
695                d.trial = None;
696                d.info_alpha_primal = last_alpha;
697                d.info_alpha_dual = 0.0;
698                d.info_alpha_primal_char = 'R';
699                d.info_ls_count = n_steps + 1;
700                return Outcome::Failed;
701            };
702            {
703                let mut d = data.borrow_mut();
704                d.set_curr(snap);
705            }
706            let theta = cq.borrow().curr_constraint_violation();
707            let phi = cq.borrow().curr_barrier_obj();
708            let d_phi = self.compute_d_phi(cq, &snap_delta);
709            // Recompute the fraction-to-the-boundary caps from the
710            // *reverted* snapshot direction at the *reverted* iterate
711            // (`curr` was just set to `snap`). This mirrors upstream
712            // `IpBacktrackingLineSearch::FindAcceptableTrialPoint`, which
713            // recomputes `alpha_primal_max` / `alpha_dual_max` from
714            // `actual_delta_` after `StopWatchDog` has reverted it to the
715            // snapshot — the whole FindAcceptableTrialPoint body re-runs
716            // on the recovered direction, caps included.
717            //
718            // The failed direction's caps (the `alpha_init` / `alpha_dual`
719            // this method was handed, sized for the pre-revert iterate and
720            // the now-abandoned search direction) are NOT reused: applying
721            // them to `snap_delta` is wrong in both directions. If the
722            // failed cap is looser than the snapshot's FTB limit, the first
723            // retry trial overshoots the boundary — a negative slack /
724            // bound-multiplier, i.e. a non-finite barrier objective — and
725            // the loop wastes trials backtracking out of infeasibility; if
726            // tighter, it needlessly shortens a feasible step. Clamp by the
727            // full step `1.0` (the default `alpha_max`), matching the main
728            // path's `alpha_init.min(alpha_primal_max)` at
729            // `ipopt_alg.rs:1045`.
730            let tau = data.borrow().curr_tau;
731            let (alpha_primal_retry, alpha_dual_retry) = {
732                let cq_ref = cq.borrow();
733                (
734                    1.0_f64.min(cq_ref.aff_step_alpha_primal_max(&snap_delta, tau)),
735                    1.0_f64.min(cq_ref.aff_step_alpha_dual_max(&snap_delta, tau)),
736                )
737            };
738            // SOC is disabled on the StopWatchDog retry. The original
739            // `search_dir` was consumed by the first alpha-loop call
740            // and we want a plain backtracking pass over the saved
741            // delta; mirrors upstream's behavior of not running the
742            // soc_method on the recovered search (hence `search_dir =
743            // None` and `skip_first = true`, which starts the retry from
744            // `alpha_*_retry * alpha_red_factor`).
745            let result2 = self.run_alpha_loop(
746                data,
747                cq,
748                &snap_delta,
749                alpha_primal_retry,
750                alpha_dual_retry,
751                nlp,
752                None,
753                theta,
754                phi,
755                d_phi,
756                /*skip_first*/ true,
757            );
758            match result2 {
759                AlphaResult::Accepted { n_steps: ns2 } => {
760                    if ns2 == 0 {
761                        self.watchdog_shortened_iter = 0;
762                    } else {
763                        self.watchdog_shortened_iter += 1;
764                    }
765                    Outcome::Accepted
766                }
767                AlphaResult::TinyStep {
768                    n_steps: ns2,
769                    last_alpha: la2,
770                } => {
771                    let mut d = data.borrow_mut();
772                    d.trial = None;
773                    d.info_alpha_primal = la2;
774                    d.info_alpha_dual = 0.0;
775                    d.info_alpha_primal_char = 'R';
776                    d.info_ls_count = ns2 + 1;
777                    Outcome::TinyStep
778                }
779                AlphaResult::Failed {
780                    n_steps: ns2,
781                    last_alpha: la2,
782                    evaluation_error: _,
783                } => {
784                    let mut d = data.borrow_mut();
785                    d.trial = None;
786                    d.info_alpha_primal = la2;
787                    d.info_alpha_dual = 0.0;
788                    d.info_alpha_primal_char = 'R';
789                    d.info_ls_count = ns2 + 1;
790                    Outcome::Failed
791                }
792            }
793        } else {
794            // Accept the last attempted trial despite filter rejection
795            // — `accept-anyway` watchdog branch
796            // (`IpBacktrackingLineSearch.cpp:498-503`). The trial
797            // iterate from the final α attempt is already in
798            // `data.trial`. Crucially, we do NOT call
799            // `update_for_next_iteration`, so the filter is NOT
800            // augmented (matching upstream's char='w' branch at
801            // line 833-836 which skips `UpdateForNextIteration`).
802            let mut d = data.borrow_mut();
803            d.info_alpha_primal = last_alpha;
804            d.info_alpha_dual = alpha_dual;
805            d.info_alpha_primal_char = 'w';
806            d.info_ls_count = n_steps + 1;
807            Outcome::Accepted
808        }
809    }
810
811    /// Inner alpha-reduction loop. Tries
812    /// `alpha = alpha_init * alpha_red_factor^k` (or
813    /// `alpha_red_factor^(k+1)` when `skip_first=true`) and consults
814    /// the acceptor against the supplied reference `(theta, phi, d_phi)`.
815    /// On accept stamps the info fields and calls
816    /// `update_for_next_iteration`. On reject leaves the LAST trial in
817    /// `data.trial` so the watchdog `accept-anyway` path can promote
818    /// it.
819    #[allow(clippy::too_many_arguments)]
820    fn run_alpha_loop(
821        &mut self,
822        data: &IpoptDataHandle,
823        cq: &IpoptCqHandle,
824        delta: &IteratesVector,
825        alpha_init: Number,
826        alpha_dual: Number,
827        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
828        search_dir: Option<&mut PdSearchDirCalc>,
829        theta: Number,
830        phi: Number,
831        d_phi: Number,
832        skip_first: bool,
833    ) -> AlphaResult {
834        let curr = match data.borrow().curr.clone() {
835            Some(c) => c,
836            None => {
837                return AlphaResult::Failed {
838                    n_steps: 0,
839                    last_alpha: 0.0,
840                    evaluation_error: false,
841                }
842            }
843        };
844
845        let mut evaluation_error = false;
846
847        let mut soc_search_dir = search_dir;
848        let (mut c_soc_buf, mut dms_soc_buf) =
849            if soc_search_dir.is_some() && nlp.is_some() && self.max_soc > 0 && !skip_first {
850                let cq_ref = cq.borrow();
851                let curr_c = cq_ref.curr_c();
852                let curr_dms = cq_ref.curr_d_minus_s();
853                let mut c_soc = curr_c.make_new();
854                c_soc.copy(&*curr_c);
855                let mut dms_soc = curr_dms.make_new();
856                dms_soc.copy(&*curr_dms);
857                (Some(c_soc), Some(dms_soc))
858            } else {
859                (None, None)
860            };
861
862        let mut alpha = if skip_first {
863            alpha_init * self.alpha_red_factor
864        } else {
865            alpha_init
866        };
867        let mut last_alpha = alpha;
868        let mut n_steps: i32 = 0;
869        // Smallest step allowed before the loop bails. Upstream
870        // `DoBacktrackingLineSearch` sets `alpha_min = alpha_primal_max`
871        // (the FTB max step) while in the watchdog window, *bypassing*
872        // the acceptor's `CalculateAlphaMin`
873        // (`IpBacktrackingLineSearch.cpp:700-704`). Together with the
874        // `|| n_steps == 0` loop guard (cpp:740) this guarantees the
875        // single full-step watchdog trial always runs, is rejected, and
876        // is then routed through the watchdog handler (accept-anyway 'w'
877        // or `StopWatchDog` revert). If pounce instead applied the
878        // acceptor floor here, a tiny FTB step under watchdog (e.g.
879        // scon1dls iter 50, alpha ~6e-13 << acceptor min) would trip the
880        // `alpha < alpha_min_eff` early-out below with zero trials and
881        // return `TinyStep`, which `run_filter_line_search` hands back
882        // directly — bypassing `handle_watchdog_failure`. The watchdog
883        // would never revert, `curr` would stay at the diverged iterate,
884        // and the solve would die with `ErrorInStepComputation` while
885        // upstream IPOPT converges.
886        let alpha_min_eff = if self.in_watchdog {
887            alpha_init
888        } else {
889            let acceptor_alpha_min = self.acceptor.calc_alpha_min(d_phi, theta);
890            self.alpha_min.max(acceptor_alpha_min)
891        };
892
893        for trial in 0..self.max_trials {
894            if alpha < alpha_min_eff {
895                return AlphaResult::TinyStep {
896                    n_steps,
897                    last_alpha,
898                };
899            }
900            last_alpha = alpha;
901            n_steps = trial;
902
903            let alpha_y = self.alpha_for_y.alpha_y(alpha, alpha_dual);
904            let trial_iv = scaled_step(&curr, delta, alpha, alpha_y, alpha_dual);
905            data.borrow_mut().set_trial(trial_iv);
906
907            let theta_trial = cq.borrow().trial_constraint_violation();
908            let phi_trial = cq.borrow().trial_barrier_obj();
909            if !theta_trial.is_finite() || !phi_trial.is_finite() {
910                // Mirror upstream `IpBacktrackingLineSearch.cpp:776-784`:
911                // a non-finite eval is treated as `Eval_Error`, sets the
912                // `evaluation_error` flag, and the alpha-loop continues
913                // to backtrack. Under watchdog, upstream breaks out
914                // immediately (line 791-794) so the watchdog handler
915                // can force StopWatchDog via line 493.
916                evaluation_error = true;
917                if self.in_watchdog {
918                    return AlphaResult::Failed {
919                        n_steps: trial,
920                        last_alpha: alpha,
921                        evaluation_error: true,
922                    };
923                }
924                alpha *= self.alpha_red_factor;
925                continue;
926            }
927
928            let decision =
929                self.acceptor
930                    .check_trial_point(alpha, theta, phi, d_phi, theta_trial, phi_trial);
931            if decision == AcceptDecision::Accept {
932                let mode = self
933                    .acceptor
934                    .update_for_next_iteration(alpha, theta, phi, d_phi, phi_trial);
935                if std::env::var_os("POUNCE_DBG_LS").is_some() {
936                    let d = data.borrow();
937                    tracing::debug!(target: "pounce::linesearch",
938                        "[PN_LS] iter={} mu={:.3e} alpha={:.3e} alpha_d={:.3e} mode={} theta={:.6e} theta_trial={:.6e} phi={:.6e} phi_trial={:.6e} n_steps={}",
939                        d.iter_count, d.curr_mu, alpha, alpha_dual, mode, theta, theta_trial, phi, phi_trial, trial
940                    );
941                }
942                let mut d = data.borrow_mut();
943                d.info_alpha_primal = alpha;
944                d.info_alpha_dual = alpha_dual;
945                d.info_ls_count = trial + 1;
946                d.info_alpha_primal_char = mode;
947                return AlphaResult::Accepted { n_steps: trial };
948            }
949
950            // Watchdog: under upstream `IpBacktrackingLineSearch.cpp:791-794`,
951            // a failed trial inside the watchdog window breaks out of the
952            // alpha-loop immediately — alpha is NOT reduced. The trial just
953            // attempted (at the full `alpha_init`) is left in `data.trial`
954            // so `handle_watchdog_failure` can promote it via the 'w'
955            // accept-anyway branch. Without this break, pounce kept
956            // reducing alpha under watchdog and accepted the same tiny
957            // step that triggered watchdog activation in the first place,
958            // leaving the iterate stalled (observed on HATFLDFLNE: iter 11
959            // accepted α=1.22e-4 'h' instead of α=1.00 'w').
960            if self.in_watchdog {
961                return AlphaResult::Failed {
962                    n_steps: trial,
963                    last_alpha: alpha,
964                    evaluation_error,
965                };
966            }
967
968            // SOC: only on the first non-skipped trial when constraint
969            // violation grew. Disabled when `skip_first=true` (no SOC
970            // buffers were allocated). Also disabled under watchdog (the
971            // `in_watchdog` break above pre-empts SOC, matching upstream
972            // which gates SOC after the in_watchdog break).
973            if trial == 0
974                && !skip_first
975                && self.max_soc > 0
976                && theta <= theta_trial
977                && c_soc_buf.is_some()
978                && dms_soc_buf.is_some()
979            {
980                let alpha_test = alpha;
981                let mut count_soc: i32 = 0;
982                let mut theta_soc_old: Number = 0.0;
983                let mut theta_trial_local = theta_trial;
984                let mut alpha_primal_soc = alpha;
985                let mut soc_accepted = false;
986                while count_soc < self.max_soc
987                    && !soc_accepted
988                    && (count_soc == 0 || theta_trial_local <= self.kappa_soc * theta_soc_old)
989                {
990                    theta_soc_old = theta_trial_local;
991                    {
992                        let cq_ref = cq.borrow();
993                        let trial_c = cq_ref.trial_c();
994                        let trial_dms = cq_ref.trial_d_minus_s();
995                        if let Some(c_soc) = c_soc_buf.as_mut() {
996                            c_soc.scal(alpha_primal_soc);
997                            c_soc.axpy(1.0, &*trial_c);
998                        }
999                        if let Some(dms_soc) = dms_soc_buf.as_mut() {
1000                            dms_soc.scal(alpha_primal_soc);
1001                            dms_soc.axpy(1.0, &*trial_dms);
1002                        }
1003                    }
1004                    let delta_soc_opt = {
1005                        let sd = soc_search_dir
1006                            .as_deref_mut()
1007                            .expect("SOC: search_dir is gated above");
1008                        let nlp_ref = nlp.expect("SOC: nlp is gated above");
1009                        let c_soc = c_soc_buf.as_deref().expect("SOC: c_soc_buf is gated above");
1010                        let dms_soc = dms_soc_buf
1011                            .as_deref()
1012                            .expect("SOC: dms_soc_buf is gated above");
1013                        sd.compute_soc_step(
1014                            data,
1015                            cq,
1016                            nlp_ref,
1017                            c_soc,
1018                            dms_soc,
1019                            alpha_primal_soc,
1020                            self.soc_method,
1021                        )
1022                    };
1023                    let Some(delta_soc) = delta_soc_opt else {
1024                        break;
1025                    };
1026                    let tau = data.borrow().curr_tau;
1027                    alpha_primal_soc = cq.borrow().aff_step_alpha_primal_max(&delta_soc, tau);
1028                    // Upstream `IpFilterLSAcceptor.cpp` sets `actual_delta =
1029                    // delta_soc` on an accepted SOC step: the *entire* step,
1030                    // primal and dual, is replaced. The dual update therefore
1031                    // uses the SOC step's own multiplier components — not the
1032                    // original `delta` — and the dual fraction-to-boundary is
1033                    // recomputed from `delta_soc`
1034                    // (`IpBacktrackingLineSearch.cpp:639`). Applying `delta`'s
1035                    // duals here left the accepted iterate with a primal from
1036                    // `delta_soc` but duals from `delta`, diverging `inf_du`
1037                    // from Ipopt on any `H`-flagged iteration (e.g. CRESC4).
1038                    let alpha_dual_soc = cq.borrow().aff_step_alpha_dual_max(&delta_soc, tau);
1039                    let mut trial_iv = curr.deep_copy();
1040                    trial_iv.x.axpy(alpha_primal_soc, &*delta_soc.x);
1041                    trial_iv.s.axpy(alpha_primal_soc, &*delta_soc.s);
1042                    trial_iv.y_c.axpy(alpha_primal_soc, &*delta_soc.y_c);
1043                    trial_iv.y_d.axpy(alpha_primal_soc, &*delta_soc.y_d);
1044                    trial_iv.z_l.axpy(alpha_dual_soc, &*delta_soc.z_l);
1045                    trial_iv.z_u.axpy(alpha_dual_soc, &*delta_soc.z_u);
1046                    trial_iv.v_l.axpy(alpha_dual_soc, &*delta_soc.v_l);
1047                    trial_iv.v_u.axpy(alpha_dual_soc, &*delta_soc.v_u);
1048                    let trial_iv = trial_iv.freeze();
1049                    data.borrow_mut().set_trial(trial_iv);
1050                    let theta_soc = cq.borrow().trial_constraint_violation();
1051                    let phi_soc = cq.borrow().trial_barrier_obj();
1052                    if !theta_soc.is_finite() || !phi_soc.is_finite() {
1053                        break;
1054                    }
1055                    let dec = self
1056                        .acceptor
1057                        .check_trial_point(alpha_test, theta, phi, d_phi, theta_soc, phi_soc);
1058                    if dec == AcceptDecision::Accept {
1059                        let mode = self
1060                            .acceptor
1061                            .update_for_next_iteration(alpha_test, theta, phi, d_phi, phi_soc);
1062                        let mut d = data.borrow_mut();
1063                        d.info_alpha_primal = alpha_primal_soc;
1064                        d.info_alpha_dual = alpha_dual_soc;
1065                        d.info_ls_count = trial + 1;
1066                        d.info_alpha_primal_char = mode.to_ascii_uppercase();
1067                        return AlphaResult::Accepted { n_steps: trial };
1068                    }
1069                    count_soc += 1;
1070                    theta_trial_local = theta_soc;
1071                    soc_accepted = false;
1072                }
1073            }
1074
1075            alpha *= self.alpha_red_factor;
1076        }
1077
1078        AlphaResult::Failed {
1079            n_steps,
1080            last_alpha,
1081            evaluation_error,
1082        }
1083    }
1084
1085    /// Directional derivative of the barrier objective along the step
1086    /// `delta`: `d_phi = ∇_x φ · dx + ∇_s φ · ds`.
1087    fn compute_d_phi(&self, cq: &IpoptCqHandle, delta: &IteratesVector) -> Number {
1088        let cq_ref = cq.borrow();
1089        let g_x = cq_ref.curr_grad_barrier_obj_x();
1090        let g_s = cq_ref.curr_grad_barrier_obj_s();
1091        g_x.dot(&*delta.x) + g_s.dot(&*delta.s)
1092    }
1093}
1094
1095/// `out = curr + alpha * delta` for all eight components, returned as a
1096/// fresh `IteratesVector` with `Rc<dyn Vector>` slots. Mirrors
1097/// `IpoptData::SetTrialBoundMultipliersFromStep` + the primal step
1098/// path in upstream — both share the same scalar α here because
1099/// fraction-to-the-boundary truncation has already been folded into
1100/// `alpha_init` upstream.
1101fn scaled_step(
1102    curr: &IteratesVector,
1103    delta: &IteratesVector,
1104    alpha_primal: Number,
1105    alpha_y: Number,
1106    alpha_dual: Number,
1107) -> IteratesVector {
1108    let mut out = curr.make_new_zeroed();
1109    out.add_one_vector(1.0, curr, 0.0); // out = curr
1110    out.x.axpy(alpha_primal, &*delta.x);
1111    out.s.axpy(alpha_primal, &*delta.s);
1112    out.y_c.axpy(alpha_y, &*delta.y_c);
1113    out.y_d.axpy(alpha_y, &*delta.y_d);
1114    out.z_l.axpy(alpha_dual, &*delta.z_l);
1115    out.z_u.axpy(alpha_dual, &*delta.z_u);
1116    out.v_l.axpy(alpha_dual, &*delta.v_l);
1117    out.v_u.axpy(alpha_dual, &*delta.v_u);
1118    out.freeze()
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124    use crate::ipopt_cq::IpoptCalculatedQuantities;
1125    use crate::ipopt_data::IpoptData;
1126    use crate::ipopt_nlp::Nlp;
1127    use crate::iterates_vector::IteratesVector;
1128    use crate::line_search::filter_acceptor::FilterLsAcceptor;
1129    use pounce_common::types::Index;
1130    use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
1131    use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
1132    use pounce_linalg::{Matrix, SymMatrix, Vector};
1133    use std::rc::Rc;
1134
1135    fn dense(n: i32, vals: &[Number]) -> Rc<dyn Vector> {
1136        let mut v = DenseVectorSpace::new(n).make_new_dense();
1137        v.set(0.0);
1138        if !vals.is_empty() {
1139            v.values_mut().copy_from_slice(vals);
1140        }
1141        Rc::new(v)
1142    }
1143
1144    fn dvec(vals: &[Number]) -> DenseVector {
1145        let mut v = DenseVectorSpace::new(vals.len() as Index).make_new_dense();
1146        v.set(0.0);
1147        if !vals.is_empty() {
1148            v.values_mut().copy_from_slice(vals);
1149        }
1150        v
1151    }
1152
1153    /// Minimal NLP for the F4 watchdog test: one variable `x[0] >= 0`,
1154    /// no constraints. `f(x) = x[0]^2`. The only finite bound is the
1155    /// lower bound on `x[0]`, so the primal fraction-to-the-boundary cap
1156    /// is governed entirely by the `x[0]` slack.
1157    struct F4MockNlp {
1158        x_l: DenseVector,
1159        x_u: DenseVector,
1160        d_l: DenseVector,
1161        d_u: DenseVector,
1162        px_l: Rc<dyn Matrix>,
1163        px_u: Rc<dyn Matrix>,
1164        pd_l: Rc<dyn Matrix>,
1165        pd_u: Rc<dyn Matrix>,
1166    }
1167
1168    impl F4MockNlp {
1169        fn new() -> Self {
1170            Self {
1171                x_l: dvec(&[0.0]),
1172                x_u: dvec(&[]),
1173                d_l: dvec(&[]),
1174                d_u: dvec(&[]),
1175                // P_L lifts the single lower-bounded var (col 0) into x[0].
1176                px_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1177                    1,
1178                    1,
1179                    &[0],
1180                    0,
1181                ))),
1182                px_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1183                    1,
1184                    0,
1185                    &[],
1186                    0,
1187                ))),
1188                pd_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1189                    0,
1190                    0,
1191                    &[],
1192                    0,
1193                ))),
1194                pd_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1195                    0,
1196                    0,
1197                    &[],
1198                    0,
1199                ))),
1200            }
1201        }
1202    }
1203
1204    impl Nlp for F4MockNlp {
1205        fn n(&self) -> Index {
1206            1
1207        }
1208        fn m_eq(&self) -> Index {
1209            0
1210        }
1211        fn m_ineq(&self) -> Index {
1212            0
1213        }
1214        fn eval_f(&mut self, x: &dyn Vector) -> Number {
1215            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1216            xx.values()[0] * xx.values()[0]
1217        }
1218        fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1219            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1220            let gg = g.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1221            gg.values_mut()[0] = 2.0 * xx.values()[0];
1222        }
1223        fn eval_c(&mut self, _x: &dyn Vector, _c: &mut dyn Vector) {}
1224        fn eval_d(&mut self, _x: &dyn Vector, _d: &mut dyn Vector) {}
1225        fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1226            unimplemented!("no equality constraints in the F4 watchdog fixture")
1227        }
1228        fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1229            unimplemented!("no inequality constraints in the F4 watchdog fixture")
1230        }
1231        fn eval_h(
1232            &mut self,
1233            _x: &dyn Vector,
1234            _obj_factor: Number,
1235            _y_c: &dyn Vector,
1236            _y_d: &dyn Vector,
1237        ) -> Rc<dyn SymMatrix> {
1238            unimplemented!("Hessian not exercised by the line search")
1239        }
1240    }
1241
1242    impl IpoptNlp for F4MockNlp {
1243        fn x_l(&self) -> &dyn Vector {
1244            &self.x_l
1245        }
1246        fn x_u(&self) -> &dyn Vector {
1247            &self.x_u
1248        }
1249        fn d_l(&self) -> &dyn Vector {
1250            &self.d_l
1251        }
1252        fn d_u(&self) -> &dyn Vector {
1253            &self.d_u
1254        }
1255        fn px_l(&self) -> Rc<dyn Matrix> {
1256            self.px_l.clone()
1257        }
1258        fn px_u(&self) -> Rc<dyn Matrix> {
1259            self.px_u.clone()
1260        }
1261        fn pd_l(&self) -> Rc<dyn Matrix> {
1262            self.pd_l.clone()
1263        }
1264        fn pd_u(&self) -> Rc<dyn Matrix> {
1265            self.pd_u.clone()
1266        }
1267    }
1268
1269    /// Acceptor that accepts the first trial unconditionally and records
1270    /// the primal step it was offered — lets the test read back the
1271    /// alpha the StopWatchDog retry started from.
1272    struct RecordingAcceptor {
1273        first_alpha: Rc<RefCell<Option<Number>>>,
1274    }
1275
1276    impl BacktrackingLsAcceptor for RecordingAcceptor {
1277        fn reset(&mut self) {}
1278        fn check_trial_point(
1279            &mut self,
1280            alpha_primal: Number,
1281            _theta: Number,
1282            _phi: Number,
1283            _d_phi: Number,
1284            _theta_trial: Number,
1285            _phi_trial: Number,
1286        ) -> AcceptDecision {
1287            let mut slot = self.first_alpha.borrow_mut();
1288            if slot.is_none() {
1289                *slot = Some(alpha_primal);
1290            }
1291            AcceptDecision::Accept
1292        }
1293    }
1294
1295    fn empty() -> Rc<dyn Vector> {
1296        dense(0, &[])
1297    }
1298
1299    /// F4 (L7 reopen): on the StopWatchDog revert, the alpha-loop retry
1300    /// must restart from the fraction-to-the-boundary cap of the
1301    /// *snapshot* direction at the *reverted* iterate — NOT the failed
1302    /// direction's cap. Pre-fix `handle_watchdog_failure` reused
1303    /// `alpha_init` (the failed direction's cap); this test pins the
1304    /// retry's first trial alpha to the recomputed snapshot cap.
1305    #[test]
1306    fn stop_watchdog_retry_recomputes_ftb_cap_from_snapshot_direction() {
1307        let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(F4MockNlp::new()));
1308        let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1309
1310        // Snapshot iterate: x = 2 (so the x[0] slack is 2), z_L = 0.5.
1311        let snap = IteratesVector::new(
1312            dense(1, &[2.0]),
1313            empty(),
1314            empty(),
1315            empty(),
1316            dense(1, &[0.5]),
1317            empty(),
1318            empty(),
1319            empty(),
1320        );
1321        {
1322            let mut d = data.borrow_mut();
1323            d.curr_mu = 0.1;
1324            d.curr_tau = 1.0;
1325            d.set_curr(snap.clone());
1326        }
1327        let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1328            data.clone(),
1329            nlp,
1330        )));
1331
1332        // Snapshot search direction: Δx = -4. At x = 2 with τ = 1 the
1333        // fraction-to-the-boundary cap is τ·s/|Δx| = 1·2/4 = 0.5.
1334        let snap_delta = IteratesVector::new(
1335            dense(1, &[-4.0]),
1336            empty(),
1337            empty(),
1338            empty(),
1339            dense(1, &[0.0]),
1340            empty(),
1341            empty(),
1342            empty(),
1343        );
1344
1345        let recorded = Rc::new(RefCell::new(None));
1346        let mut bls = BacktrackingLineSearch::new(Box::new(RecordingAcceptor {
1347            first_alpha: recorded.clone(),
1348        }));
1349
1350        // Arm the watchdog at the snapshot and put it one trial over the
1351        // cap, so the next failure triggers StopWatchDog (revert + retry).
1352        bls.in_watchdog = true;
1353        bls.watchdog_iterate = Some(snap.clone());
1354        bls.watchdog_delta = Some(snap_delta);
1355        bls.watchdog_trial_iter = bls.watchdog_trial_iter_max;
1356
1357        let outcome = bls.handle_watchdog_failure(
1358            &data, &cq, /*alpha_dual*/ 1.0, None, /*n_steps*/ 0, /*last_alpha*/ 1.0,
1359            /*evaluation_error*/ false,
1360        );
1361        assert_eq!(outcome, Outcome::Accepted);
1362
1363        // skip_first halves the recomputed cap: 0.5 × alpha_red_factor
1364        // (0.5) = 0.25. The failed direction's cap would differ.
1365        let a = recorded
1366            .borrow()
1367            .expect("acceptor must have seen at least one trial");
1368        assert!(
1369            (a - 0.25).abs() < 1e-12,
1370            "retry first alpha = {a}, expected 0.25 (snapshot FTB cap 0.5 × red 0.5)"
1371        );
1372    }
1373
1374    fn iv_from(x: &[Number], s: &[Number]) -> IteratesVector {
1375        IteratesVector::new(
1376            dense(x.len() as i32, x),
1377            dense(s.len() as i32, s),
1378            dense(0, &[]),
1379            dense(0, &[]),
1380            dense(0, &[]),
1381            dense(0, &[]),
1382            dense(0, &[]),
1383            dense(0, &[]),
1384        )
1385    }
1386
1387    #[test]
1388    fn driver_constructs_with_defaults() {
1389        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1390        assert_eq!(bls.alpha_red_factor, 0.5);
1391        assert_eq!(bls.max_soc, 4);
1392    }
1393
1394    #[test]
1395    fn scaled_step_writes_curr_plus_alpha_delta() {
1396        // curr.x = (0,0), delta.x = (1,1) → at alpha=0.5, trial.x = (0.5, 0.5).
1397        let curr = iv_from(&[0.0, 0.0], &[0.0]);
1398        let delta = iv_from(&[1.0, 1.0], &[2.0]);
1399        let trial = scaled_step(&curr, &delta, 0.5, 0.5, 0.5);
1400        let xv = trial
1401            .x
1402            .as_any()
1403            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1404            .unwrap()
1405            .values()
1406            .to_vec();
1407        assert_eq!(xv, vec![0.5, 0.5]);
1408        let sv = trial
1409            .s
1410            .as_any()
1411            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1412            .unwrap()
1413            .values()
1414            .to_vec();
1415        assert_eq!(sv, vec![1.0]); // 0.0 + 0.5 * 2.0
1416    }
1417
1418    #[test]
1419    fn outcome_variants_are_distinct() {
1420        assert_ne!(Outcome::Accepted, Outcome::Failed);
1421        assert_ne!(Outcome::Accepted, Outcome::TinyStep);
1422        assert_ne!(Outcome::Failed, Outcome::TinyStep);
1423    }
1424
1425    #[test]
1426    fn watchdog_state_starts_inactive() {
1427        // Mirror upstream `IpBacktrackingLineSearch::InitializeImpl`
1428        // (`IpBacktrackingLineSearch.cpp:240-249`): the watchdog is
1429        // inactive at construction and `last_mu_` is initialised to
1430        // a sentinel `-1` so the first iteration's mu always
1431        // triggers the reset branch (which is harmless when the
1432        // watchdog was never armed).
1433        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1434        assert!(!bls.in_watchdog());
1435        assert_eq!(bls.watchdog_shortened_iter(), 0);
1436        assert!(bls.last_mu < 0.0);
1437        assert_eq!(bls.watchdog_shortened_iter_trigger, 10);
1438        assert_eq!(bls.watchdog_trial_iter_max, 3);
1439    }
1440
1441    #[test]
1442    fn alpha_result_failed_carries_n_steps_and_last_alpha() {
1443        // Sanity check on the internal AlphaResult enum: the watchdog
1444        // wrapper relies on `Failed { n_steps, last_alpha }` to stamp
1445        // the info-* fields when handing off to restoration.
1446        let r = AlphaResult::Failed {
1447            n_steps: 7,
1448            last_alpha: 1e-6,
1449            evaluation_error: false,
1450        };
1451        match r {
1452            AlphaResult::Failed {
1453                n_steps,
1454                last_alpha,
1455                evaluation_error,
1456            } => {
1457                assert_eq!(n_steps, 7);
1458                assert!((last_alpha - 1e-6).abs() < 1e-20);
1459                assert!(!evaluation_error);
1460            }
1461            _ => unreachable!(),
1462        }
1463    }
1464}