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