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