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/// Number of trial points the plain geometric sequence gets to itself
35/// before [`BacktrackingLineSearch::next_alpha`]'s quadratic
36/// interpolation is allowed to pick the next `alpha` (gh #818).
37///
38/// **The interpolation is a treatment for a long line search, and it is
39/// only harmless where the line search is long.** gh #818 is a
40/// quasi-Newton model whose *scale* is wrong by orders of magnitude in
41/// the directions its curvature pairs do not span: the acceptable step
42/// is `alpha ~ 4e-6`, and halving walks there in 19-20 trial points,
43/// every one of them a full objective evaluation, every iteration. A
44/// line search that accepts in two or three trials does not have that
45/// problem, and interpolating into it replaces a step length the filter
46/// was about to accept with a different one — a trajectory change
47/// bought for nothing.
48///
49/// Measured, `scripts/sweep-fixtures.sh` against `a5e0a837`, 156
50/// fixture-legs, every row taken against that one baseline. The `exact`
51/// leg is byte-identical at every value, because `alpha_red_factor_min`
52/// resolves to `alpha_red_factor` there, so every line below is an
53/// `lbfgs` leg. **Bold is a status the baseline did not have.**
54///
55/// | this constant | legs moved | `cresc4` `RestoFailed`/1323 | `deb7` `ErrInStep`/1242 | `eigena2` `ErrInStep`/252 | `square_flowsheet_resto` `Infeasible`/3000 |
56/// |---|---|---|---|---|---|
57/// | 0 (interpolate always) | 12 | `Succeeded`/241 | **`RestoFailed`**/2381 | 131 | **`Succeeded`**/2393 |
58/// | 2 | 7 | `RestoFailed`/215 | **`MaxIter`**/3000 | 170 | `Infeasible`/49 |
59/// | 3 | 6 | `Succeeded`/226 | `ErrInStep`/1327 | 109 | `Infeasible`/2022 |
60/// | 5 | 4 | `Succeeded`/264 | **`RestoFailed`**/455 | 91 | unmoved |
61/// | **6** (shipped) | **4** | `Succeeded`/281 | `ErrInStep`/1010 | 201 | unmoved |
62///
63/// Six is the only value measured that gains a status and loses none.
64/// `cresc4` is the gain at every gate that reaches it; what separates 6
65/// from its neighbours is `deb7`, which changes verdict at 0, 2 **and
66/// 5** and keeps `ErrorInStepComputation` only at 3 and 6, and of those
67/// two only 6 shortens it (1242 -> 1010 against 3's 1327). The fourth
68/// moving line at 5 and 6 is one objective digit on `hs13_bigstart` at
69/// an unchanged iteration count.
70///
71/// **Why not 5, which an earlier revision shipped.** Two reasons, and
72/// the second is the one that forced the change. `deb7` above is the
73/// first. The second is `python/tests/test_starts_racing.py`: one
74/// `_rastrigin_eq` line search in that suite reaches exactly 5-6 trial
75/// points, so a gate of 5 interpolates into it and reroutes the whole
76/// multistart race. Halving-against-fixed `solver_evals` over that
77/// suite reads 1.022 with the interpolation off, **1.320 at gate 5**,
78/// and 1.019 / 1.022 / 1.022 at gates 6 / 7 / 8 — two red assertions,
79/// at 2870 `user_evals` and 2169 `solver_evals` against budgets of
80/// 2800 and 2006. Six restores that suite to its interpolation-off
81/// numbers (1949 `solver_evals` against 1951 off) without giving up
82/// gh #818.
83///
84/// **`square_flowsheet_resto` is a pre-existing wrong verdict on the
85/// baseline, not something a gate value creates.** An earlier revision
86/// of this comment had it the other way round — that interpolating
87/// from the first trial reports a model feasible by construction as
88/// converged to a point of local infeasibility. Re-measured against
89/// `a5e0a837`, the *baseline* is the `InfeasibleProblemDetected`/3000
90/// line and gate 0 is what turns it into `SolveSucceeded`/2393. The
91/// original measurement predates gh #817, which is in `main` now and
92/// moved that fixture's `lbfgs` leg. Gates 5 and 6 do not touch the
93/// line either way, so this constant is not the lever for it; it is
94/// recorded here so the next reader does not inherit the inverted
95/// claim.
96///
97/// It also fixes gh #818's own model. On the issue's quadratic at the
98/// default memory `m = 6` and `limited_memory_initialization=scalar1`,
99/// `before` being `alpha_red_factor_min` set equal to
100/// `alpha_red_factor` — upstream's fixed sequence, bit for bit:
101///
102/// | | before gh #818 | **gated at 6** |
103/// |---|---|---|
104/// | `n = 4` (the report) | 76 | **22** |
105/// | `n = 8`, `m = 10` | 74 | **61** |
106///
107/// The 8-variable case at the *default* memory is not on that list.
108/// It is not closed by this change and is not closed by any gate:
109/// see `issue_818_eight_variable_default_memory_does_not_stall` in
110/// `pounce-rs/tests/issue_818_lbfgs_illconditioned_quadratic.rs`,
111/// which carries the two-knob sweep showing it converging in 5 of 15
112/// cells with no pattern. Tuning this constant to that cell is what
113/// produced the gate of 5 and the racing regression above.
114///
115/// One cell of the 32-cell model sweep — `n = 8` at cond 1e12 with
116/// `m = 6` — exited `Diverging_Iterates` at 352 under every gate in
117/// {5, 8, 12, 20} and every `alpha_red_factor_min` measured, which is
118/// what it looks like when the constant is not the variable. It was
119/// not: the interpolation was reaching a *watchdog* trial excursion
120/// that the divergence guard in `IpoptAlgorithm::iterate` then read as
121/// unboundedness, on a point [`Self::handle_watchdog_failure`] had
122/// already rejected and was holding a snapshot for. With that guard
123/// fixed the cell no longer claims divergence — it reports
124/// `Error_In_Step_Computation` at 521, still not a solve, but at a
125/// better objective than the fixed sequence reaches in four times the
126/// iterations (6.4e-11 against 2.8e-10 at `max_iter`). See
127/// `pounce-rs/tests/watchdog_trial_is_not_a_divergence_verdict.rs`;
128/// CHANGELOG.md carries the full grid.
129///
130/// Not a registered option. `alpha_red_factor_min = alpha_red_factor`
131/// already turns the interpolation off entirely, which is the escape
132/// hatch a caller needs; a second knob between "off" and "on" would be
133/// one more number with no population behind it.
134const ALPHA_INTERP_MIN_TRIALS: i32 = 6;
135
136/// Outcome of the backtracking line search. Mirrors the booleans
137/// upstream returns through `accept_` plus the `tiny_step_flag` on
138/// `IpoptData`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Outcome {
141    /// Trial point accepted at the recorded `alpha`.
142    Accepted,
143    /// `alpha` fell below `alpha_min_frac` × current α₀ ⇒ tiny step.
144    /// Caller maps to `STEP_BECOMES_TINY` in upstream's exception flow.
145    TinyStep,
146    /// All α reductions rejected; the caller hands off to restoration.
147    Failed,
148    /// The shared wall/CPU-time deadline was crossed mid-search
149    /// (pounce#242). The caller terminates the solve with the
150    /// corresponding time-limit status, returning the current best
151    /// iterate (`data.curr`, left untouched — no trial was promoted).
152    Deadline,
153}
154
155/// Policy for the step length applied to the equality multipliers
156/// `y_c`, `y_d`. Mirrors upstream's `alpha_for_y` option (subset of
157/// the upstream enum — pounce only ports the variants that the
158/// Mehrotra cascade and default code paths exercise).
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum AlphaForY {
161    /// Use the primal step length (upstream default).
162    Primal,
163    /// Use the dual step length. Selected by the Mehrotra cascade
164    /// (`alpha_for_y=bound-mult`).
165    BoundMult,
166    /// Always take a full step on the equality multipliers.
167    Full,
168    /// Use the minimum of the primal and dual step lengths.
169    Min,
170    /// Use the maximum of the primal and dual step lengths.
171    Max,
172    /// Use the arithmetic mean of the primal and dual step lengths.
173    Average,
174}
175
176impl AlphaForY {
177    /// Compute the actual step length for `y_c`, `y_d` given the
178    /// already-selected primal and dual step lengths.
179    pub fn alpha_y(self, alpha_primal: Number, alpha_dual: Number) -> Number {
180        match self {
181            AlphaForY::Primal => alpha_primal,
182            AlphaForY::BoundMult => alpha_dual,
183            AlphaForY::Full => 1.0,
184            AlphaForY::Min => alpha_primal.min(alpha_dual),
185            AlphaForY::Max => alpha_primal.max(alpha_dual),
186            AlphaForY::Average => 0.5 * (alpha_primal + alpha_dual),
187        }
188    }
189}
190
191pub struct BacktrackingLineSearch {
192    pub acceptor: Box<dyn BacktrackingLsAcceptor>,
193    pub alpha_red_factor: Number,
194    /// `alpha_red_factor_min` — the *floor* on one backtracking
195    /// reduction, which is what turns the fixed geometric sequence into
196    /// a safeguarded interpolation (gh#818). See
197    /// [`BacktrackingLineSearch::next_alpha`]. Setting it equal to
198    /// `alpha_red_factor` collapses the clamp and restores the plain
199    /// `alpha *= alpha_red_factor` sequence.
200    ///
201    /// The `0.05` here is the *direct-construction* default (tests and
202    /// drivers that assemble a line search by hand).
203    /// `AlgorithmBuilder::build` overwrites it from
204    /// `LineSearchOptions::alpha_red_factor_min`, which resolves to
205    /// `0.05` under `limited-memory` and to `alpha_red_factor` — i.e.
206    /// off — under an exact Hessian; that field's doc carries the
207    /// measurement behind the split.
208    pub alpha_red_factor_min: Number,
209    pub max_soc: i32,
210    /// Threshold for the SOC outer-loop convergence test
211    /// `theta_trial <= kappa_soc * theta_soc_old`. Mirrors upstream's
212    /// `kappa_soc` (default 0.99).
213    pub kappa_soc: Number,
214    /// SOC RHS variant. `0` = upstream default ("old"), `1` = scaled
215    /// gradient-block variant. Both correspond to upstream's
216    /// `soc_method` option.
217    pub soc_method: i32,
218    /// Number of consecutive shortened iterations before the watchdog
219    /// procedure activates. Disabled when `<= 0`. Mirrors upstream's
220    /// `watchdog_shortened_iter_trigger` (default 10).
221    pub watchdog_shortened_iter_trigger: i32,
222    /// Maximum number of outer iterations the watchdog will accept
223    /// non-decreasing trial points before reverting to the snapshot.
224    /// Mirrors upstream's `watchdog_trial_iter_max` (default 3).
225    pub watchdog_trial_iter_max: i32,
226    /// Lower bound on α; below this we declare a tiny step (mirrors
227    /// `alpha_min_frac` flow, `IpBacktrackingLineSearch.cpp:CalculateAlphaMin`).
228    pub alpha_min: Number,
229    /// Maximum trial-iteration cap before declaring failure.
230    pub max_trials: i32,
231
232    // ---- Watchdog state (port of `IpBacktrackingLineSearch.{hpp,cpp}`'s
233    //      `in_watchdog_`, `watchdog_iterate_`, `watchdog_delta_`,
234    //      `watchdog_alpha_primal_test_`, `watchdog_trial_iter_`,
235    //      `watchdog_shortened_iter_`, `last_mu_`).
236    //
237    // Watchdog mechanism: after `watchdog_shortened_iter_trigger`
238    // consecutive shortened (n_steps > 0) accepts, we snapshot the
239    // current iterate `(curr, delta, theta, phi, d_phi)` and enter
240    // watchdog mode. While in watchdog: the acceptor's reference
241    // values are FROZEN to the snapshot for up to
242    // `watchdog_trial_iter_max` outer iterations. Each iteration's
243    // alpha-loop runs against the frozen reference; if it accepts,
244    // watchdog terminates with success ("W"). If it rejects, we
245    // accept the last trial anyway (info char 'w') and let the next
246    // outer iteration try again. If `watchdog_trial_iter_max` outer
247    // iterations all reject, we revert to the snapshot and re-run
248    // the alpha-loop on the saved `delta` with `skip_first=true`.
249    /// True iff currently inside a watchdog window.
250    in_watchdog: bool,
251    /// Snapshot of the iterate at watchdog activation.
252    watchdog_iterate: Option<IteratesVector>,
253    /// Snapshot of the search direction at watchdog activation.
254    watchdog_delta: Option<IteratesVector>,
255    /// Number of outer iterations elapsed since watchdog activation.
256    watchdog_trial_iter: i32,
257    /// Number of consecutive shortened (n_steps > 0) accepts.
258    /// Reset on a full step (n_steps == 0), on mu change, on watchdog
259    /// success, and on watchdog stop-with-revert.
260    watchdog_shortened_iter: i32,
261    /// `mu` at the previous outer iteration. A change clears the
262    /// watchdog state (`IpBacktrackingLineSearch.cpp:259-270`).
263    last_mu: Number,
264    /// Frozen reference theta at watchdog activation.
265    watchdog_theta: Number,
266    /// Frozen reference phi at watchdog activation.
267    watchdog_phi: Number,
268    /// Frozen reference d_phi at watchdog activation.
269    watchdog_d_phi: Number,
270
271    // ---- Soft restoration phase (port of `IpBacktrackingLineSearch`'s
272    //      `in_soft_resto_phase_`, `soft_resto_counter_`).
273    //
274    // When the regular filter line search fails, before handing off to
275    // the full (sub-NLP) restoration phase, the driver tries a single
276    // damped primal-dual step along the *same* search direction. The
277    // step is damped only by the fraction-to-the-boundary rule and is
278    // accepted if it either satisfies the original filter criterion
279    // ('S' — leave soft resto) or merely reduces the primal-dual KKT
280    // system error by `soft_resto_pderror_reduction_factor` ('s' —
281    // stay in soft resto). Subsequent outer iterations keep taking
282    // soft-resto steps until the original criterion is met, the step
283    // is rejected, or `max_soft_resto_iters` consecutive iterations
284    // elapse — any of which drops through to full restoration.
285    /// Required relative reduction in the primal-dual system error for
286    /// a soft-resto step to be accepted. `0` disables soft restoration.
287    /// Mirrors upstream `soft_resto_pderror_reduction_factor`
288    /// (default `1 - 1e-4`).
289    pub soft_resto_pderror_reduction_factor: Number,
290    /// Cap on consecutive soft-resto iterations before full
291    /// restoration is forced. Mirrors upstream `max_soft_resto_iters`
292    /// (default 10).
293    pub max_soft_resto_iters: i32,
294    /// True iff the driver is currently inside the soft-resto phase.
295    in_soft_resto_phase: bool,
296    /// Count of consecutive soft-resto iterations taken so far.
297    soft_resto_counter: i32,
298
299    /// `accept_every_trial_step` — when true, the alpha loop and filter
300    /// are bypassed: the FTB-truncated `alpha_init`/`alpha_dual` step
301    /// is set as the trial and accepted unconditionally. Mirrors
302    /// upstream's `IpBacktrackingLineSearch.cpp:accept_every_trial_step_`
303    /// short-circuit at the top of `FindAcceptableTrialPoint`.
304    pub accept_every_trial_step: bool,
305    /// `alpha_for_y` policy applied to the equality multipliers `y_c`,
306    /// `y_d` when constructing the trial iterate. See [`AlphaForY`].
307    pub alpha_for_y: AlphaForY,
308    /// `accept_after_max_steps` — once this many backtracking steps have
309    /// been taken in one line search, the trial point is accepted
310    /// without consulting the acceptor. `-1` (the default, and
311    /// upstream's) disables the escape hatch entirely, so the field is
312    /// inert unless a caller sets it.
313    ///
314    /// Port of `IpBacktrackingLineSearch.cpp:759-770`: upstream
315    /// evaluates the trial barrier objective and constraint violation
316    /// first (so an evaluation error still backtracks — the finiteness
317    /// check in the alpha loop is pounce's equivalent), tags the
318    /// iteration `MaxS`, and calls `Reset()` — leaving the soft
319    /// restoration phase and resetting the acceptor — before accepting.
320    ///
321    /// Like `accept_every_trial_step`, this drops the global
322    /// convergence guarantee: the accepted point satisfies neither the
323    /// filter nor the Armijo condition.
324    pub accept_after_max_steps: i32,
325}
326
327/// Internal alpha-loop outcome. The watchdog wrapper translates this
328/// into the public [`Outcome`] after applying its state machine.
329enum AlphaResult {
330    /// Trial accepted at `alpha_used` after `n_steps` reductions.
331    Accepted { n_steps: i32 },
332    /// α dropped below `alpha_min_eff` ⇒ tiny step. `last_alpha` is
333    /// the smallest α actually evaluated; `n_steps` is the number of
334    /// reductions performed.
335    TinyStep { n_steps: i32, last_alpha: Number },
336    /// `max_trials` exhausted without acceptance. The last attempted
337    /// trial iterate is left in `data.trial` so the watchdog
338    /// "accept-anyway" path can promote it.
339    ///
340    /// `evaluation_error` flags that the last attempted trial produced
341    /// a non-finite `theta_trial`/`phi_trial` — mirrors upstream's
342    /// `evaluation_error` tracked from `IpoptNLP::Eval_Error`
343    /// (`IpBacktrackingLineSearch.cpp:776-784`). The watchdog handler
344    /// must treat this as a forced StopWatchDog
345    /// (`IpBacktrackingLineSearch.cpp:493`) — accepting a non-finite
346    /// iterate via the 'w' branch propagates NaN/Inf into the next
347    /// outer iter (observed on PFIT3 iter 53: inf_pr=7.87e305 from a
348    /// 'w'-accepted trial; on PFIT4 iter 31: inf_pr=1.01e11).
349    Failed {
350        n_steps: i32,
351        last_alpha: Number,
352        evaluation_error: bool,
353    },
354    /// The shared wall/CPU-time deadline was crossed before a trial was
355    /// accepted (pounce#242). Propagated up as [`Outcome::Deadline`].
356    Deadline,
357}
358
359impl BacktrackingLineSearch {
360    pub fn new(acceptor: Box<dyn BacktrackingLsAcceptor>) -> Self {
361        Self {
362            acceptor,
363            alpha_red_factor: 0.5,
364            alpha_red_factor_min: 0.05,
365            max_soc: 4,
366            kappa_soc: 0.99,
367            soc_method: 0,
368            watchdog_shortened_iter_trigger: 10,
369            watchdog_trial_iter_max: 3,
370            alpha_min: 1e-12,
371            max_trials: 50,
372            in_watchdog: false,
373            watchdog_iterate: None,
374            watchdog_delta: None,
375            watchdog_trial_iter: 0,
376            watchdog_shortened_iter: 0,
377            last_mu: -1.0,
378            watchdog_theta: 0.0,
379            watchdog_phi: 0.0,
380            watchdog_d_phi: 0.0,
381            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
382            max_soft_resto_iters: 10,
383            in_soft_resto_phase: false,
384            soft_resto_counter: 0,
385            accept_every_trial_step: false,
386            alpha_for_y: AlphaForY::Primal,
387            accept_after_max_steps: -1,
388        }
389    }
390
391    /// Whether the line search is inside a watchdog trial sequence.
392    ///
393    /// While this is `true` the iterate in `data.curr` is **provisional**:
394    /// it was promoted through the `accept-anyway` branch of
395    /// [`Self::handle_watchdog_failure`] (info char `'w'`) despite the
396    /// acceptor *rejecting* it, the filter was deliberately not augmented,
397    /// and a snapshot of the pre-watchdog iterate and direction is held in
398    /// `watchdog_iterate` / `watchdog_delta`. Within
399    /// `watchdog_trial_iter_max` (default 3) further iterations the line
400    /// search either finds the gamble paid off or executes `StopWatchDog`
401    /// and reverts to that snapshot.
402    ///
403    /// The outer algorithm reads this so a *terminal* verdict is never
404    /// pronounced on a point the line search itself has already rejected
405    /// and is holding a replacement for — see the divergence guard in
406    /// [`crate::ipopt_alg::IpoptAlgorithm::iterate`].
407    pub(crate) fn in_watchdog(&self) -> bool {
408        self.in_watchdog
409    }
410
411    /// Test-only accessor for the shortened-iter counter.
412    #[cfg(test)]
413    pub(crate) fn watchdog_shortened_iter(&self) -> i32 {
414        self.watchdog_shortened_iter
415    }
416
417    pub fn acceptor(&self) -> &dyn BacktrackingLsAcceptor {
418        &*self.acceptor
419    }
420
421    pub fn acceptor_mut(&mut self) -> &mut dyn BacktrackingLsAcceptor {
422        &mut *self.acceptor
423    }
424
425    /// Reset the acceptor state at the start of a new outer iteration.
426    pub fn reset(&mut self) {
427        self.acceptor.reset();
428    }
429
430    /// Clear the globalization heuristics' cross-iteration counters
431    /// after the full restoration phase has *succeeded* — port of
432    /// `IpBacktrackingLineSearch.cpp:624-631`.
433    ///
434    /// Upstream calls `PerformRestoration()` from inside
435    /// `FindAcceptableTrialPoint`, so these four assignments sit
436    /// directly after it and the state is in scope. pounce hands the
437    /// restoration off to the caller (`IpoptAlgorithm::invoke_restoration`)
438    /// and returns `Outcome::Failed`, so the reset has to be driven from
439    /// there instead — see the `RestorationOutcome::Recovered` arm.
440    ///
441    /// Getting this wrong is not cosmetic. `watchdog_shortened_iter`
442    /// counts *consecutive* shortened steps, and the watchdog arms at
443    /// `watchdog_shortened_iter_trigger` (default 10). A restoration
444    /// episode is not a shortened step — it is a different point — so
445    /// carrying the count across one lets runs of shortened steps that
446    /// are separated by restoration accumulate as if they were
447    /// consecutive. On `steenbrf` that is exactly what happened: five
448    /// shortened steps before restoration plus five after reached the
449    /// trigger, the watchdog armed, spent its three trial iterations
450    /// and reverted to the pre-watchdog point, and the line search then
451    /// collapsed to alpha ~1e-08 with 20+ backtracks. That cycle
452    /// repeated 105 times and the solve hit `max_iter`; with the reset
453    /// in place the counter never reaches the trigger (upstream
454    /// Ipopt's longest run on this problem is 6) and the same
455    /// trajectory converges.
456    ///
457    /// `count_successive_shortened_steps_` (cpp:624) is not ported —
458    /// upstream reads it only under `expect_infeasible_problem_`
459    /// (cpp:798-804), which pounce does not implement.
460    pub fn reset_after_restoration(&mut self) {
461        self.in_soft_resto_phase = false;
462        self.soft_resto_counter = 0;
463        self.watchdog_shortened_iter = 0;
464    }
465
466    /// Public line-search entry point. Wraps the regular filter line
467    /// search ([`Self::run_filter_line_search`]) with the soft
468    /// restoration phase — port of the `in_soft_resto_phase_` state
469    /// machine in `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
470    /// (`IpBacktrackingLineSearch.cpp:439-465` for the in-phase
471    /// continuation, `:528-556` for entering the phase).
472    ///
473    /// Outcomes:
474    /// - `Accepted`: a trial point is in `data.trial` — either a
475    ///   regular filter/watchdog step or a soft-resto step (info char
476    ///   's' = stay in soft resto, 'S' = step also satisfies the
477    ///   original filter so soft resto is left).
478    /// - `TinyStep` / `Failed`: neither the regular line search nor a
479    ///   soft-resto step could make progress; the caller hands off to
480    ///   the full restoration phase.
481    #[allow(clippy::too_many_arguments)]
482    pub fn find_acceptable_trial_point(
483        &mut self,
484        data: &IpoptDataHandle,
485        cq: &IpoptCqHandle,
486        delta: &IteratesVector,
487        alpha_init: Number,
488        alpha_dual: Number,
489        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
490        search_dir: Option<&mut PdSearchDirCalc>,
491    ) -> Outcome {
492        // ---- `accept_every_trial_step` short-circuit. Mirrors the
493        // unglobalized path at the top of
494        // `IpBacktrackingLineSearch::FindAcceptableTrialPoint` (when
495        // `accept_every_trial_step_` is true): no soft-resto, no
496        // watchdog, no alpha loop, no filter update — just take the
497        // FTB-truncated step (`alpha_init`, `alpha_dual` already
498        // include the fraction-to-the-boundary rule) and accept it
499        // unconditionally. Used by the Mehrotra cascade.
500        if self.accept_every_trial_step {
501            let curr = match data.borrow().curr.clone() {
502                Some(c) => c,
503                None => return Outcome::Failed,
504            };
505            let alpha_y = self.alpha_for_y.alpha_y(alpha_init, alpha_dual);
506            let trial_iv = scaled_step(&curr, delta, alpha_init, alpha_y, alpha_dual);
507            let mut d = data.borrow_mut();
508            d.set_trial(trial_iv);
509            d.info_alpha_primal = alpha_init;
510            d.info_alpha_dual = alpha_dual;
511            d.info_alpha_primal_char = ' ';
512            d.info_ls_count = 1;
513            return Outcome::Accepted;
514        }
515
516        // ---- Soft-resto continuation. Already inside the phase: bump
517        // the counter, bail to full restoration once it exceeds
518        // `max_soft_resto_iters`, otherwise take another damped
519        // primal-dual step along the caller's `delta`
520        // (`IpBacktrackingLineSearch.cpp:439-465`).
521        if self.in_soft_resto_phase {
522            self.soft_resto_counter += 1;
523            if self.soft_resto_counter > self.max_soft_resto_iters {
524                self.in_soft_resto_phase = false;
525                self.soft_resto_counter = 0;
526                return self.fail_to_restoration(data);
527            }
528            // Per-outer-iteration acceptor hook (no-op for the filter
529            // acceptor; the penalty acceptor caches its reference here).
530            self.acceptor.init_this_line_search(data, cq, delta);
531            return match self.try_soft_resto_step(data, cq, delta) {
532                Some(satisfies_original) => {
533                    if satisfies_original {
534                        self.in_soft_resto_phase = false;
535                        self.soft_resto_counter = 0;
536                        data.borrow_mut().info_alpha_primal_char = 'S';
537                    } else {
538                        data.borrow_mut().info_alpha_primal_char = 's';
539                    }
540                    Outcome::Accepted
541                }
542                None => {
543                    self.in_soft_resto_phase = false;
544                    self.soft_resto_counter = 0;
545                    self.fail_to_restoration(data)
546                }
547            };
548        }
549
550        // ---- Regular filter line search (watchdog + alpha loop).
551        let outcome =
552            self.run_filter_line_search(data, cq, delta, alpha_init, alpha_dual, nlp, search_dir);
553        if outcome == Outcome::Accepted {
554            return Outcome::Accepted;
555        }
556        // Time budget crossed (pounce#242): the caller is stopping the
557        // solve, so skip the soft-restoration attempt and hand the
558        // terminal outcome straight back.
559        if outcome == Outcome::Deadline {
560            return Outcome::Deadline;
561        }
562
563        // ---- Regular line search failed. Before the (expensive) full
564        // restoration sub-NLP, try to *enter* the soft restoration
565        // phase with one damped primal-dual step
566        // (`IpBacktrackingLineSearch.cpp:528-556`). `prepare_resto_phase_start`
567        // augments the outer filter with the entry envelope — mirrors
568        // upstream's `acceptor_->PrepareRestoPhaseStart()` at line 537.
569        let reference_theta = cq.borrow().curr_constraint_violation();
570        let reference_barr = cq.borrow().curr_barrier_obj();
571        self.acceptor
572            .prepare_resto_phase_start(reference_theta, reference_barr);
573        match self.try_soft_resto_step(data, cq, delta) {
574            Some(satisfies_original) => {
575                if satisfies_original {
576                    data.borrow_mut().info_alpha_primal_char = 'S';
577                } else {
578                    self.in_soft_resto_phase = true;
579                    self.soft_resto_counter = 0;
580                    data.borrow_mut().info_alpha_primal_char = 's';
581                }
582                Outcome::Accepted
583            }
584            // Soft resto could not help — fall through to full
585            // restoration with the original failure outcome. The
586            // caller's `invoke_restoration` re-runs
587            // `prepare_resto_phase_start`; the duplicate filter
588            // augmentation is idempotent (same envelope).
589            None => outcome,
590        }
591    }
592
593    /// Stamp the info fields for a hand-off to the full restoration
594    /// phase and return `Outcome::Failed`. Used when the soft
595    /// restoration phase exhausts its iteration budget or its step is
596    /// rejected mid-phase.
597    fn fail_to_restoration(&self, data: &IpoptDataHandle) -> Outcome {
598        let mut d = data.borrow_mut();
599        d.trial = None;
600        d.info_alpha_primal = 0.0;
601        d.info_alpha_dual = 0.0;
602        d.info_alpha_primal_char = 'R';
603        d.info_ls_count = 0;
604        Outcome::Failed
605    }
606
607    /// Attempt a single damped primal-dual step for the soft
608    /// restoration phase — port of
609    /// `BacktrackingLineSearch::TrySoftRestoStep`
610    /// (`IpBacktrackingLineSearch.cpp:1112-1217`). The step along
611    /// `delta` is damped only by the fraction-to-the-boundary rule,
612    /// with an identical step length for primal and dual variables.
613    ///
614    /// Returns:
615    /// - `Some(true)`  — trial accepted *and* it satisfies the
616    ///   original filter criterion ⇒ caller leaves soft resto ('S').
617    /// - `Some(false)` — trial accepted only on the primal-dual error
618    ///   reduction test ⇒ caller stays in soft resto ('s').
619    /// - `None`        — trial rejected (or soft resto disabled / a
620    ///   non-finite evaluation) ⇒ caller falls through to the full
621    ///   restoration phase.
622    ///
623    /// On a `Some(_)` return the accepted trial is left in `data.trial`
624    /// and the numeric `info_*` fields are stamped; the caller stamps
625    /// `info_alpha_primal_char`.
626    fn try_soft_resto_step(
627        &mut self,
628        data: &IpoptDataHandle,
629        cq: &IpoptCqHandle,
630        delta: &IteratesVector,
631    ) -> Option<bool> {
632        // Soft restoration is disabled when the reduction factor is
633        // zero (`IpBacktrackingLineSearch.cpp:1124`).
634        if self.soft_resto_pderror_reduction_factor == 0.0 {
635            return None;
636        }
637        let curr = data.borrow().curr.clone()?;
638        let tau = data.borrow().curr_tau;
639
640        // Identical step length for primal and dual variables, damped
641        // only by the fraction-to-the-boundary rule
642        // (`IpBacktrackingLineSearch.cpp:1135-1140`).
643        let alpha = {
644            let cq_ref = cq.borrow();
645            cq_ref
646                .aff_step_alpha_primal_max(delta, tau)
647                .min(cq_ref.aff_step_alpha_dual_max(delta, tau))
648        };
649
650        // Soft-resto uses the same scalar α for primal, equality
651        // multipliers, and bound multipliers (per upstream).
652        let trial_iv = scaled_step(&curr, delta, alpha, alpha, alpha);
653        data.borrow_mut().set_trial(trial_iv);
654
655        let theta_trial = cq.borrow().trial_constraint_violation();
656        let phi_trial = cq.borrow().trial_barrier_obj();
657        if !theta_trial.is_finite() || !phi_trial.is_finite() {
658            // Upstream retries up to three times on `Eval_Error`; the
659            // step length is fixed, so a non-finite eval here is
660            // deterministic — treat it as a rejection.
661            return None;
662        }
663
664        let theta = cq.borrow().curr_constraint_violation();
665        let phi = cq.borrow().curr_barrier_obj();
666        let d_phi = self.compute_d_phi(cq, delta);
667
668        // First test: is the trial acceptable to the *original*
669        // backtracking globalization? Upstream
670        // `acceptor_->CheckAcceptabilityOfTrialPoint(0.)`.
671        if self
672            .acceptor
673            .check_trial_point(0.0, theta, phi, d_phi, theta_trial, phi_trial)
674            == AcceptDecision::Accept
675        {
676            let mut d = data.borrow_mut();
677            d.info_alpha_primal = alpha;
678            d.info_alpha_dual = alpha;
679            d.info_ls_count = 1;
680            return Some(true);
681        }
682
683        // Second test: sufficient reduction in the primal-dual KKT
684        // system error (`IpBacktrackingLineSearch.cpp:1184-1211`).
685        let mu = data.borrow().curr_mu;
686        let curr_pderror = cq.borrow().curr_primal_dual_system_error(mu);
687        let trial_pderror = cq.borrow().trial_primal_dual_system_error(mu);
688        if !trial_pderror.is_finite() {
689            return None;
690        }
691        if trial_pderror <= self.soft_resto_pderror_reduction_factor * curr_pderror {
692            let mut d = data.borrow_mut();
693            d.info_alpha_primal = alpha;
694            d.info_alpha_dual = alpha;
695            d.info_ls_count = 1;
696            return Some(false);
697        }
698        None
699    }
700
701    /// Drive the watchdog state machine + alpha-reduction loop.
702    /// Port of `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
703    /// (`IpBacktrackingLineSearch.cpp:252-677`) restricted to the
704    /// regular (non-soft-resto) filter-acceptor, exact-Hessian path.
705    /// The soft restoration phase is layered on top by
706    /// [`Self::find_acceptable_trial_point`].
707    ///
708    /// Outcomes:
709    /// - `Accepted`: a trial point is in `data.trial`, info fields are
710    ///   stamped. The watchdog state has been advanced (success → "W",
711    ///   `accept-anyway` → 'w').
712    /// - `TinyStep`: α dropped below the dynamic alpha-min before any
713    ///   trial was accepted. Caller hands off to restoration.
714    /// - `Failed`: alpha-loop exhausted AND watchdog could not rescue.
715    ///   Caller hands off to restoration.
716    #[allow(clippy::too_many_arguments)]
717    fn run_filter_line_search(
718        &mut self,
719        data: &IpoptDataHandle,
720        cq: &IpoptCqHandle,
721        delta: &IteratesVector,
722        alpha_init: Number,
723        alpha_dual: Number,
724        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
725        search_dir: Option<&mut PdSearchDirCalc>,
726    ) -> Outcome {
727        // ---- Watchdog: detect mu change → reset state.
728        // Mirrors `IpBacktrackingLineSearch.cpp:259-270`.
729        let curr_mu = data.borrow().curr_mu;
730        if self.last_mu < 0.0 || self.last_mu != curr_mu {
731            self.in_watchdog = false;
732            self.watchdog_iterate = None;
733            self.watchdog_delta = None;
734            self.watchdog_shortened_iter = 0;
735            self.last_mu = curr_mu;
736        }
737
738        // ---- Watchdog: maybe wake up.
739        // Mirrors `IpBacktrackingLineSearch.cpp:376-380`.
740        if !self.in_watchdog
741            && self.watchdog_shortened_iter_trigger > 0
742            && self.watchdog_shortened_iter >= self.watchdog_shortened_iter_trigger
743        {
744            self.start_watchdog(data, cq, delta);
745        }
746
747        // Tell the acceptor how many constraint rows back `theta`'s
748        // 1-norm, so its `theta_max` reference can be floored in
749        // per-row rather than absolute units. Guarded inside the
750        // acceptor to be a no-op once `theta_max` has locked, so this
751        // only ever takes effect on the first line search of a solve.
752        self.acceptor
753            .set_theta_rows(cq.borrow().constraint_violation_rows() as Number);
754
755        // Per-outer-iteration acceptor hook.
756        self.acceptor.init_this_line_search(data, cq, delta);
757
758        // Decide reference (theta, phi, d_phi). Mirrors upstream's
759        // `FilterLSAcceptor::InitThisLineSearch(in_watchdog)` choice
760        // between `curr_*` and the saved `watchdog_*` snapshot.
761        let (theta, phi, d_phi) = if self.in_watchdog {
762            (self.watchdog_theta, self.watchdog_phi, self.watchdog_d_phi)
763        } else {
764            let theta = cq.borrow().curr_constraint_violation();
765            let phi = cq.borrow().curr_barrier_obj();
766            let d_phi = self.compute_d_phi(cq, delta);
767            (theta, phi, d_phi)
768        };
769
770        // Run the alpha-loop on the caller's `delta`.
771        let result = self.run_alpha_loop(
772            data, cq, delta, alpha_init, alpha_dual, nlp, search_dir, theta, phi, d_phi,
773            /*skip_first*/ false,
774        );
775
776        match result {
777            AlphaResult::Accepted { n_steps } => {
778                // Update the shortened-iter counter
779                // (`IpBacktrackingLineSearch.cpp:644-655`).
780                if n_steps == 0 {
781                    self.watchdog_shortened_iter = 0;
782                } else {
783                    self.watchdog_shortened_iter += 1;
784                }
785                if self.in_watchdog {
786                    // Watchdog success — clear state, info char already
787                    // stamped by the alpha loop's
788                    // `update_for_next_iteration` call. Upstream also
789                    // appends "W" to the info string here; pounce
790                    // doesn't track an info string yet.
791                    self.in_watchdog = false;
792                    self.watchdog_iterate = None;
793                    self.watchdog_delta = None;
794                    self.watchdog_shortened_iter = 0;
795                }
796                Outcome::Accepted
797            }
798            AlphaResult::TinyStep {
799                n_steps,
800                last_alpha,
801            } => {
802                let mut d = data.borrow_mut();
803                d.trial = None;
804                d.info_alpha_primal = last_alpha;
805                d.info_alpha_dual = 0.0;
806                d.info_alpha_primal_char = 'R';
807                d.info_ls_count = n_steps + 1;
808                Outcome::TinyStep
809            }
810            AlphaResult::Failed {
811                n_steps,
812                last_alpha,
813                evaluation_error,
814            } => {
815                if self.in_watchdog {
816                    self.handle_watchdog_failure(
817                        data,
818                        cq,
819                        alpha_dual,
820                        nlp,
821                        n_steps,
822                        last_alpha,
823                        evaluation_error,
824                    )
825                } else {
826                    // Genuine failure → restoration.
827                    let mut d = data.borrow_mut();
828                    d.trial = None;
829                    d.info_alpha_primal = last_alpha;
830                    d.info_alpha_dual = 0.0;
831                    d.info_alpha_primal_char = 'R';
832                    d.info_ls_count = n_steps + 1;
833                    Outcome::Failed
834                }
835            }
836            // Time budget crossed mid-loop (pounce#242) — terminal, and it
837            // pre-empts the watchdog: there is no point reverting to a
838            // snapshot when the caller is about to stop the solve.
839            AlphaResult::Deadline => Outcome::Deadline,
840        }
841    }
842
843    /// Snapshot the current `(curr, delta, theta, phi, d_phi)` and
844    /// activate the watchdog. Mirrors upstream
845    /// `IpBacktrackingLineSearch::StartWatchDog`
846    /// (`IpBacktrackingLineSearch.cpp:855-869`) plus
847    /// `IpFilterLSAcceptor::StartWatchDog`
848    /// (`IpFilterLSAcceptor.cpp:506-513`) — pounce stores the
849    /// frozen reference values directly on the driver because the
850    /// acceptor is stateless w.r.t. reference values (the driver
851    /// passes them per call).
852    fn start_watchdog(
853        &mut self,
854        data: &IpoptDataHandle,
855        cq: &IpoptCqHandle,
856        delta: &IteratesVector,
857    ) {
858        let curr = data.borrow().curr.clone();
859        let Some(curr) = curr else {
860            return;
861        };
862        self.in_watchdog = true;
863        self.watchdog_iterate = Some(curr);
864        self.watchdog_delta = Some(delta.clone());
865        self.watchdog_trial_iter = 0;
866        self.watchdog_theta = cq.borrow().curr_constraint_violation();
867        self.watchdog_phi = cq.borrow().curr_barrier_obj();
868        self.watchdog_d_phi = self.compute_d_phi(cq, delta);
869    }
870
871    /// Handle alpha-loop failure while in watchdog mode. Bumps
872    /// `watchdog_trial_iter`; if the cap is exceeded, reverts to the
873    /// snapshot (StopWatchDog) and re-runs the alpha-loop on the
874    /// saved `delta` with `skip_first=true`. Otherwise accepts the
875    /// current trial as 'w' and returns. Mirrors
876    /// `IpBacktrackingLineSearch.cpp:480-503` together with
877    /// `IpBacktrackingLineSearch.cpp:871-908`'s `StopWatchDog`.
878    fn handle_watchdog_failure(
879        &mut self,
880        data: &IpoptDataHandle,
881        cq: &IpoptCqHandle,
882        alpha_dual: Number,
883        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
884        n_steps: i32,
885        last_alpha: Number,
886        evaluation_error: bool,
887    ) -> Outcome {
888        self.watchdog_trial_iter += 1;
889        // Mirror upstream `IpBacktrackingLineSearch.cpp:493`:
890        // `if (evaluation_error || watchdog_trial_iter > max)` →
891        // StopWatchDog. A non-finite trial must NOT be promoted via
892        // the 'w' accept-anyway path; doing so propagates NaN/Inf
893        // into the next outer iter and the iterate is unrecoverable
894        // (observed on PFIT3, PFIT4).
895        if evaluation_error || self.watchdog_trial_iter > self.watchdog_trial_iter_max {
896            // StopWatchDog: revert curr to the snapshot, re-run on
897            // saved delta with `skip_first=true` (alpha starts at
898            // `alpha_init * alpha_red_factor`).
899            let snapshot_iter = self.watchdog_iterate.take();
900            let snapshot_delta = self.watchdog_delta.take();
901            self.in_watchdog = false;
902            self.watchdog_shortened_iter = 0;
903            let (Some(snap), Some(snap_delta)) = (snapshot_iter, snapshot_delta) else {
904                // Defensive — this should not happen if start_watchdog
905                // ran successfully. Fall through to genuine failure.
906                let mut d = data.borrow_mut();
907                d.trial = None;
908                d.info_alpha_primal = last_alpha;
909                d.info_alpha_dual = 0.0;
910                d.info_alpha_primal_char = 'R';
911                d.info_ls_count = n_steps + 1;
912                return Outcome::Failed;
913            };
914            {
915                let mut d = data.borrow_mut();
916                d.set_curr(snap);
917            }
918            let theta = cq.borrow().curr_constraint_violation();
919            let phi = cq.borrow().curr_barrier_obj();
920            let d_phi = self.compute_d_phi(cq, &snap_delta);
921            // Recompute the fraction-to-the-boundary caps from the
922            // *reverted* snapshot direction at the *reverted* iterate
923            // (`curr` was just set to `snap`). This mirrors upstream
924            // `IpBacktrackingLineSearch::FindAcceptableTrialPoint`, which
925            // recomputes `alpha_primal_max` / `alpha_dual_max` from
926            // `actual_delta_` after `StopWatchDog` has reverted it to the
927            // snapshot — the whole FindAcceptableTrialPoint body re-runs
928            // on the recovered direction, caps included.
929            //
930            // The failed direction's caps (the `alpha_init` / `alpha_dual`
931            // this method was handed, sized for the pre-revert iterate and
932            // the now-abandoned search direction) are NOT reused: applying
933            // them to `snap_delta` is wrong in both directions. If the
934            // failed cap is looser than the snapshot's FTB limit, the first
935            // retry trial overshoots the boundary — a negative slack /
936            // bound-multiplier, i.e. a non-finite barrier objective — and
937            // the loop wastes trials backtracking out of infeasibility; if
938            // tighter, it needlessly shortens a feasible step. Clamp by the
939            // full step `1.0` (the default `alpha_max`), matching the main
940            // path's `alpha_init.min(alpha_primal_max)` at
941            // `ipopt_alg.rs:1045`.
942            let tau = data.borrow().curr_tau;
943            let (alpha_primal_retry, alpha_dual_retry) = {
944                let cq_ref = cq.borrow();
945                (
946                    1.0_f64.min(cq_ref.aff_step_alpha_primal_max(&snap_delta, tau)),
947                    1.0_f64.min(cq_ref.aff_step_alpha_dual_max(&snap_delta, tau)),
948                )
949            };
950            // SOC is disabled on the StopWatchDog retry. The original
951            // `search_dir` was consumed by the first alpha-loop call
952            // and we want a plain backtracking pass over the saved
953            // delta; mirrors upstream's behavior of not running the
954            // soc_method on the recovered search (hence `search_dir =
955            // None` and `skip_first = true`, which starts the retry from
956            // `alpha_*_retry * alpha_red_factor`).
957            let result2 = self.run_alpha_loop(
958                data,
959                cq,
960                &snap_delta,
961                alpha_primal_retry,
962                alpha_dual_retry,
963                nlp,
964                None,
965                theta,
966                phi,
967                d_phi,
968                /*skip_first*/ true,
969            );
970            match result2 {
971                AlphaResult::Accepted { n_steps: ns2 } => {
972                    if ns2 == 0 {
973                        self.watchdog_shortened_iter = 0;
974                    } else {
975                        self.watchdog_shortened_iter += 1;
976                    }
977                    Outcome::Accepted
978                }
979                AlphaResult::TinyStep {
980                    n_steps: ns2,
981                    last_alpha: la2,
982                } => {
983                    let mut d = data.borrow_mut();
984                    d.trial = None;
985                    d.info_alpha_primal = la2;
986                    d.info_alpha_dual = 0.0;
987                    d.info_alpha_primal_char = 'R';
988                    d.info_ls_count = ns2 + 1;
989                    Outcome::TinyStep
990                }
991                AlphaResult::Failed {
992                    n_steps: ns2,
993                    last_alpha: la2,
994                    evaluation_error: _,
995                } => {
996                    let mut d = data.borrow_mut();
997                    d.trial = None;
998                    d.info_alpha_primal = la2;
999                    d.info_alpha_dual = 0.0;
1000                    d.info_alpha_primal_char = 'R';
1001                    d.info_ls_count = ns2 + 1;
1002                    Outcome::Failed
1003                }
1004                // Deadline crossed during the StopWatchDog retry sweep
1005                // (pounce#242) — propagate the terminal outcome.
1006                AlphaResult::Deadline => Outcome::Deadline,
1007            }
1008        } else {
1009            // Accept the last attempted trial despite filter rejection
1010            // — `accept-anyway` watchdog branch
1011            // (`IpBacktrackingLineSearch.cpp:498-503`). The trial
1012            // iterate from the final α attempt is already in
1013            // `data.trial`. Crucially, we do NOT call
1014            // `update_for_next_iteration`, so the filter is NOT
1015            // augmented (matching upstream's char='w' branch at
1016            // line 833-836 which skips `UpdateForNextIteration`).
1017            let mut d = data.borrow_mut();
1018            d.info_alpha_primal = last_alpha;
1019            d.info_alpha_dual = alpha_dual;
1020            d.info_alpha_primal_char = 'w';
1021            d.info_ls_count = n_steps + 1;
1022            Outcome::Accepted
1023        }
1024    }
1025
1026    /// Inner alpha-reduction loop. Tries
1027    /// `alpha = alpha_init * alpha_red_factor^k` (or
1028    /// `alpha_red_factor^(k+1)` when `skip_first=true`) and consults
1029    /// the acceptor against the supplied reference `(theta, phi, d_phi)`.
1030    /// On accept stamps the info fields and calls
1031    /// `update_for_next_iteration`. On reject leaves the LAST trial in
1032    /// `data.trial` so the watchdog `accept-anyway` path can promote
1033    /// it.
1034    #[allow(clippy::too_many_arguments)]
1035    fn run_alpha_loop(
1036        &mut self,
1037        data: &IpoptDataHandle,
1038        cq: &IpoptCqHandle,
1039        delta: &IteratesVector,
1040        alpha_init: Number,
1041        alpha_dual: Number,
1042        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
1043        search_dir: Option<&mut PdSearchDirCalc>,
1044        theta: Number,
1045        phi: Number,
1046        d_phi: Number,
1047        skip_first: bool,
1048    ) -> AlphaResult {
1049        let curr = match data.borrow().curr.clone() {
1050            Some(c) => c,
1051            None => {
1052                return AlphaResult::Failed {
1053                    n_steps: 0,
1054                    last_alpha: 0.0,
1055                    evaluation_error: false,
1056                };
1057            }
1058        };
1059
1060        let mut evaluation_error = false;
1061
1062        let mut soc_search_dir = search_dir;
1063        let (mut c_soc_buf, mut dms_soc_buf) =
1064            if soc_search_dir.is_some() && nlp.is_some() && self.max_soc > 0 && !skip_first {
1065                let cq_ref = cq.borrow();
1066                let curr_c = cq_ref.curr_c();
1067                let curr_dms = cq_ref.curr_d_minus_s();
1068                let mut c_soc = curr_c.make_new();
1069                c_soc.copy(&*curr_c);
1070                let mut dms_soc = curr_dms.make_new();
1071                dms_soc.copy(&*curr_dms);
1072                (Some(c_soc), Some(dms_soc))
1073            } else {
1074                (None, None)
1075            };
1076
1077        let mut alpha = if skip_first {
1078            alpha_init * self.alpha_red_factor
1079        } else {
1080            alpha_init
1081        };
1082        let mut last_alpha = alpha;
1083        let mut n_steps: i32 = 0;
1084        // Smallest step allowed before the loop bails. Upstream
1085        // `DoBacktrackingLineSearch` sets `alpha_min = alpha_primal_max`
1086        // (the FTB max step) while in the watchdog window, *bypassing*
1087        // the acceptor's `CalculateAlphaMin`
1088        // (`IpBacktrackingLineSearch.cpp:700-704`). Together with the
1089        // `|| n_steps == 0` loop guard (cpp:740) this guarantees the
1090        // single full-step watchdog trial always runs, is rejected, and
1091        // is then routed through the watchdog handler (accept-anyway 'w'
1092        // or `StopWatchDog` revert). If pounce instead applied the
1093        // acceptor floor here, a tiny FTB step under watchdog (e.g.
1094        // scon1dls iter 50, alpha ~6e-13 << acceptor min) would trip the
1095        // `alpha < alpha_min_eff` early-out below with zero trials and
1096        // return `TinyStep`, which `run_filter_line_search` hands back
1097        // directly — bypassing `handle_watchdog_failure`. The watchdog
1098        // would never revert, `curr` would stay at the diverged iterate,
1099        // and the solve would die with `ErrorInStepComputation` while
1100        // upstream IPOPT converges.
1101        let alpha_min_eff = if self.in_watchdog {
1102            alpha_init
1103        } else {
1104            let acceptor_alpha_min = self.acceptor.calc_alpha_min(d_phi, theta);
1105            self.alpha_min.max(acceptor_alpha_min)
1106        };
1107
1108        for trial in 0..self.max_trials {
1109            // Fine-grained time-budget gate (pounce#242): each trial
1110            // evaluates the constraints / barrier objective, which on a
1111            // large problem is not cheap, so honor the deadline at
1112            // per-trial granularity rather than letting a full backtracking
1113            // sweep run past it. Bail before staging another trial; no
1114            // trial is promoted, so `data.curr` stays the best iterate.
1115            if data
1116                .borrow()
1117                .deadline
1118                .as_ref()
1119                .is_some_and(|dl| dl.exceeded().is_some())
1120            {
1121                return AlphaResult::Deadline;
1122            }
1123            if alpha < alpha_min_eff {
1124                return AlphaResult::TinyStep {
1125                    n_steps,
1126                    last_alpha,
1127                };
1128            }
1129            last_alpha = alpha;
1130            n_steps = trial;
1131
1132            let alpha_y = self.alpha_for_y.alpha_y(alpha, alpha_dual);
1133            let trial_iv = scaled_step(&curr, delta, alpha, alpha_y, alpha_dual);
1134            data.borrow_mut().set_trial(trial_iv);
1135
1136            let theta_trial = cq.borrow().trial_constraint_violation();
1137            let phi_trial = cq.borrow().trial_barrier_obj();
1138            if !theta_trial.is_finite() || !phi_trial.is_finite() {
1139                // Mirror upstream `IpBacktrackingLineSearch.cpp:776-784`:
1140                // a non-finite eval is treated as `Eval_Error`, sets the
1141                // `evaluation_error` flag, and the alpha-loop continues
1142                // to backtrack. Under watchdog, upstream breaks out
1143                // immediately (line 791-794) so the watchdog handler
1144                // can force StopWatchDog via line 493.
1145                evaluation_error = true;
1146                if self.in_watchdog {
1147                    return AlphaResult::Failed {
1148                        n_steps: trial,
1149                        last_alpha: alpha,
1150                        evaluation_error: true,
1151                    };
1152                }
1153                alpha *= self.alpha_red_factor;
1154                continue;
1155            }
1156
1157            // `accept_after_max_steps` (upstream
1158            // `IpBacktrackingLineSearch.cpp:759-770`): once this many
1159            // backtracking steps have been taken, take the point
1160            // whatever the acceptor thinks of it. Upstream evaluates
1161            // the trial objective/violation first so an evaluation
1162            // error still backtracks — that is the finiteness check
1163            // just above — then calls `Reset()` (leave soft resto,
1164            // reset the acceptor) and accepts. `-1` disables it, so a
1165            // solve that does not set the option never takes this
1166            // branch and the acceptor decides as before.
1167            let force_accept =
1168                self.accept_after_max_steps >= 0 && trial >= self.accept_after_max_steps;
1169            let decision = if force_accept {
1170                self.in_soft_resto_phase = false;
1171                self.soft_resto_counter = 0;
1172                self.acceptor.reset();
1173                AcceptDecision::Accept
1174            } else {
1175                self.acceptor
1176                    .check_trial_point(alpha, theta, phi, d_phi, theta_trial, phi_trial)
1177            };
1178            if decision == AcceptDecision::Accept {
1179                let mode = self
1180                    .acceptor
1181                    .update_for_next_iteration(alpha, theta, phi, d_phi, phi_trial);
1182                if std::env::var_os("POUNCE_DBG_LS").is_some() {
1183                    let d = data.borrow();
1184                    tracing::debug!(target: "pounce::linesearch",
1185                        "[PN_LS] iter={} mu={:.3e} alpha={:.3e} alpha_d={:.3e} mode={} theta={:.6e} theta_trial={:.6e} phi={:.6e} phi_trial={:.6e} n_steps={}",
1186                        d.iter_count, d.curr_mu, alpha, alpha_dual, mode, theta, theta_trial, phi, phi_trial, trial
1187                    );
1188                }
1189                let mut d = data.borrow_mut();
1190                d.info_alpha_primal = alpha;
1191                d.info_alpha_dual = alpha_dual;
1192                d.info_ls_count = trial + 1;
1193                d.info_alpha_primal_char = mode;
1194                return AlphaResult::Accepted { n_steps: trial };
1195            }
1196
1197            // Watchdog: under upstream `IpBacktrackingLineSearch.cpp:791-794`,
1198            // a failed trial inside the watchdog window breaks out of the
1199            // alpha-loop immediately — alpha is NOT reduced. The trial just
1200            // attempted (at the full `alpha_init`) is left in `data.trial`
1201            // so `handle_watchdog_failure` can promote it via the 'w'
1202            // accept-anyway branch. Without this break, pounce kept
1203            // reducing alpha under watchdog and accepted the same tiny
1204            // step that triggered watchdog activation in the first place,
1205            // leaving the iterate stalled (observed on HATFLDFLNE: iter 11
1206            // accepted α=1.22e-4 'h' instead of α=1.00 'w').
1207            if self.in_watchdog {
1208                return AlphaResult::Failed {
1209                    n_steps: trial,
1210                    last_alpha: alpha,
1211                    evaluation_error,
1212                };
1213            }
1214
1215            // SOC: only on the first non-skipped trial when constraint
1216            // violation grew. Disabled when `skip_first=true` (no SOC
1217            // buffers were allocated). Also disabled under watchdog (the
1218            // `in_watchdog` break above pre-empts SOC, matching upstream
1219            // which gates SOC after the in_watchdog break).
1220            if trial == 0
1221                && !skip_first
1222                && self.max_soc > 0
1223                && theta <= theta_trial
1224                && c_soc_buf.is_some()
1225                && dms_soc_buf.is_some()
1226            {
1227                let alpha_test = alpha;
1228                let mut count_soc: i32 = 0;
1229                let mut theta_soc_old: Number = 0.0;
1230                let mut theta_trial_local = theta_trial;
1231                let mut alpha_primal_soc = alpha;
1232                let mut soc_accepted = false;
1233                while count_soc < self.max_soc
1234                    && !soc_accepted
1235                    && (count_soc == 0 || theta_trial_local <= self.kappa_soc * theta_soc_old)
1236                {
1237                    theta_soc_old = theta_trial_local;
1238                    {
1239                        let cq_ref = cq.borrow();
1240                        let trial_c = cq_ref.trial_c();
1241                        let trial_dms = cq_ref.trial_d_minus_s();
1242                        if let Some(c_soc) = c_soc_buf.as_mut() {
1243                            c_soc.scal(alpha_primal_soc);
1244                            c_soc.axpy(1.0, &*trial_c);
1245                        }
1246                        if let Some(dms_soc) = dms_soc_buf.as_mut() {
1247                            dms_soc.scal(alpha_primal_soc);
1248                            dms_soc.axpy(1.0, &*trial_dms);
1249                        }
1250                    }
1251                    let delta_soc_opt = {
1252                        let sd = soc_search_dir
1253                            .as_deref_mut()
1254                            .expect("SOC: search_dir is gated above");
1255                        let nlp_ref = nlp.expect("SOC: nlp is gated above");
1256                        let c_soc = c_soc_buf.as_deref().expect("SOC: c_soc_buf is gated above");
1257                        let dms_soc = dms_soc_buf
1258                            .as_deref()
1259                            .expect("SOC: dms_soc_buf is gated above");
1260                        sd.compute_soc_step(
1261                            data,
1262                            cq,
1263                            nlp_ref,
1264                            c_soc,
1265                            dms_soc,
1266                            alpha_primal_soc,
1267                            self.soc_method,
1268                        )
1269                    };
1270                    let Some(delta_soc) = delta_soc_opt else {
1271                        break;
1272                    };
1273                    let tau = data.borrow().curr_tau;
1274                    alpha_primal_soc = cq.borrow().aff_step_alpha_primal_max(&delta_soc, tau);
1275                    // Upstream `IpFilterLSAcceptor.cpp` sets `actual_delta =
1276                    // delta_soc` on an accepted SOC step: the *entire* step,
1277                    // primal and dual, is replaced. The dual update therefore
1278                    // uses the SOC step's own multiplier components — not the
1279                    // original `delta` — and the dual fraction-to-boundary is
1280                    // recomputed from `delta_soc`
1281                    // (`IpBacktrackingLineSearch.cpp:639`). Applying `delta`'s
1282                    // duals here left the accepted iterate with a primal from
1283                    // `delta_soc` but duals from `delta`, diverging `inf_du`
1284                    // from Ipopt on any `H`-flagged iteration (e.g. CRESC4).
1285                    let alpha_dual_soc = cq.borrow().aff_step_alpha_dual_max(&delta_soc, tau);
1286                    let mut trial_iv = curr.deep_copy();
1287                    trial_iv.x.axpy(alpha_primal_soc, &*delta_soc.x);
1288                    trial_iv.s.axpy(alpha_primal_soc, &*delta_soc.s);
1289                    trial_iv.y_c.axpy(alpha_primal_soc, &*delta_soc.y_c);
1290                    trial_iv.y_d.axpy(alpha_primal_soc, &*delta_soc.y_d);
1291                    trial_iv.z_l.axpy(alpha_dual_soc, &*delta_soc.z_l);
1292                    trial_iv.z_u.axpy(alpha_dual_soc, &*delta_soc.z_u);
1293                    trial_iv.v_l.axpy(alpha_dual_soc, &*delta_soc.v_l);
1294                    trial_iv.v_u.axpy(alpha_dual_soc, &*delta_soc.v_u);
1295                    let trial_iv = trial_iv.freeze();
1296                    data.borrow_mut().set_trial(trial_iv);
1297                    let theta_soc = cq.borrow().trial_constraint_violation();
1298                    let phi_soc = cq.borrow().trial_barrier_obj();
1299                    if !theta_soc.is_finite() || !phi_soc.is_finite() {
1300                        break;
1301                    }
1302                    let dec = self
1303                        .acceptor
1304                        .check_trial_point(alpha_test, theta, phi, d_phi, theta_soc, phi_soc);
1305                    if dec == AcceptDecision::Accept {
1306                        let mode = self
1307                            .acceptor
1308                            .update_for_next_iteration(alpha_test, theta, phi, d_phi, phi_soc);
1309                        let mut d = data.borrow_mut();
1310                        d.info_alpha_primal = alpha_primal_soc;
1311                        d.info_alpha_dual = alpha_dual_soc;
1312                        d.info_ls_count = trial + 1;
1313                        d.info_alpha_primal_char = mode.to_ascii_uppercase();
1314                        return AlphaResult::Accepted { n_steps: trial };
1315                    }
1316                    count_soc += 1;
1317                    theta_trial_local = theta_soc;
1318                    soc_accepted = false;
1319                }
1320            }
1321
1322            alpha = if trial < ALPHA_INTERP_MIN_TRIALS {
1323                // The fixed sequence gets the first `ALPHA_INTERP_MIN_TRIALS`
1324                // trials to itself; see that constant for why.
1325                alpha * self.alpha_red_factor
1326            } else {
1327                self.next_alpha(alpha, phi, d_phi, phi_trial)
1328            };
1329        }
1330
1331        AlphaResult::Failed {
1332            n_steps,
1333            last_alpha,
1334            evaluation_error,
1335        }
1336    }
1337
1338    /// The next backtracking trial step, given that `alpha` was
1339    /// rejected and `phi_trial = φ(alpha)` was measured there.
1340    ///
1341    /// Upstream reduces by a fixed factor, `alpha *= alpha_red_factor`
1342    /// (`IpBacktrackingLineSearch.cpp`), which walks down in halves and
1343    /// so needs `log₂(1/α*)` trial points to reach a step of size `α*`.
1344    /// That is cheap when the model is roughly right and ruinous when
1345    /// it is not: on gh#818's unconstrained ill-conditioned quadratic
1346    /// the limited-memory model understates the curvature along `d` by
1347    /// six orders of magnitude, the acceptable step is `α ≈ 4e-6`, and
1348    /// every iteration spends 19–20 trial points — each a full
1349    /// objective evaluation — walking there. Under `alpha_red_factor
1350    /// 0.2` (nine trials instead of twenty) the same solve goes from
1351    /// `Maximum_Iterations_Exceeded` at 2000 iterations to converged at
1352    /// 1099, which is the measurement that says the trial *sequence*,
1353    /// not the acceptance test, is what costs.
1354    ///
1355    /// So instead of a fixed factor, fit the quadratic through
1356    /// `(0, φ)`, `(0, φ')` and `(alpha, φ(alpha))` and jump to its
1357    /// minimizer — the textbook safeguarded backtracking step
1358    /// (Nocedal & Wright, *Numerical Optimization* §3.5;
1359    /// Dennis & Schnabel Alg. A6.3.1). This is a heuristic for *which*
1360    /// `alpha` to try next and nothing more: the acceptor still decides
1361    /// whether a trial is taken, so no step this method proposes can be
1362    /// accepted that the fixed-factor sequence would have rejected.
1363    ///
1364    /// Two safeguards keep it bounded, and they are what make the
1365    /// change safe rather than merely faster:
1366    ///
1367    /// * **Never slower than upstream.** The result is capped at
1368    ///   `alpha_red_factor · alpha`, so the trial sequence still
1369    ///   contracts at least as fast as the plain geometric one and the
1370    ///   `alpha < alpha_min_eff` bail is still reached in a bounded
1371    ///   number of trials.
1372    /// * **Never a collapse.** The result is floored at
1373    ///   `alpha_red_factor_min · alpha` (default 0.05, i.e. at most a
1374    ///   20× reduction per trial), so one badly-shaped `φ` cannot drop
1375    ///   `alpha` to noise in a single step and skip past an acceptable
1376    ///   interval. The cap wins if a caller inverts the pair, so the
1377    ///   two options can be set in either order without aborting the
1378    ///   solve.
1379    ///
1380    /// Falls back to the fixed factor whenever the interpolation is not
1381    /// defined: a non-descent `d_phi` (the quadratic has no positive
1382    /// minimizer), a non-finite `phi_trial`, or a non-positive
1383    /// denominator (`φ(alpha)` below the tangent line, i.e. the fit is
1384    /// concave and its stationary point is a maximum).
1385    fn next_alpha(&self, alpha: Number, phi: Number, d_phi: Number, phi_trial: Number) -> Number {
1386        let fixed = alpha * self.alpha_red_factor;
1387        if !(d_phi < 0.0) || !phi_trial.is_finite() || !phi.is_finite() {
1388            return fixed;
1389        }
1390        // φ(α) ≈ φ + φ'·α + c·α², with c pinned by the measured
1391        // `phi_trial`; the minimizer is −φ'/(2c).
1392        let denom = 2.0 * (phi_trial - phi - d_phi * alpha);
1393        if !(denom > 0.0) {
1394            return fixed;
1395        }
1396        let alpha_q = -d_phi * alpha * alpha / denom;
1397        if !alpha_q.is_finite() {
1398            return fixed;
1399        }
1400        // `f64::clamp` panics when `min > max`, and nothing constrains
1401        // the two options against each other: the registry accepts
1402        // `alpha_red_factor` anywhere in (0, 1) while the limited-memory
1403        // default installs `alpha_red_factor_min = 0.05` regardless, so
1404        // a lone `alpha_red_factor 0.01` is enough to invert them. The
1405        // cap wins the tie, because "never slower than upstream" is the
1406        // safeguard that bounds the trial count and the floor is only
1407        // there to stop a collapse.
1408        let floor = (alpha * self.alpha_red_factor_min).min(fixed);
1409        alpha_q.clamp(floor, fixed)
1410    }
1411
1412    /// Directional derivative of the barrier objective along the step
1413    /// `delta`: `d_phi = ∇_x φ · dx + ∇_s φ · ds`.
1414    fn compute_d_phi(&self, cq: &IpoptCqHandle, delta: &IteratesVector) -> Number {
1415        let cq_ref = cq.borrow();
1416        let g_x = cq_ref.curr_grad_barrier_obj_x();
1417        let g_s = cq_ref.curr_grad_barrier_obj_s();
1418        g_x.dot(&*delta.x) + g_s.dot(&*delta.s)
1419    }
1420}
1421
1422/// `out = curr + alpha * delta` for all eight components, returned as a
1423/// fresh `IteratesVector` with `Rc<dyn Vector>` slots. Mirrors
1424/// `IpoptData::SetTrialBoundMultipliersFromStep` + the primal step
1425/// path in upstream — both share the same scalar α here because
1426/// fraction-to-the-boundary truncation has already been folded into
1427/// `alpha_init` upstream.
1428fn scaled_step(
1429    curr: &IteratesVector,
1430    delta: &IteratesVector,
1431    alpha_primal: Number,
1432    alpha_y: Number,
1433    alpha_dual: Number,
1434) -> IteratesVector {
1435    let mut out = curr.make_new_zeroed();
1436    out.add_one_vector(1.0, curr, 0.0); // out = curr
1437    out.x.axpy(alpha_primal, &*delta.x);
1438    out.s.axpy(alpha_primal, &*delta.s);
1439    out.y_c.axpy(alpha_y, &*delta.y_c);
1440    out.y_d.axpy(alpha_y, &*delta.y_d);
1441    out.z_l.axpy(alpha_dual, &*delta.z_l);
1442    out.z_u.axpy(alpha_dual, &*delta.z_u);
1443    out.v_l.axpy(alpha_dual, &*delta.v_l);
1444    out.v_u.axpy(alpha_dual, &*delta.v_u);
1445    out.freeze()
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451    use crate::ipopt_cq::IpoptCalculatedQuantities;
1452    use crate::ipopt_data::IpoptData;
1453    use crate::ipopt_nlp::Nlp;
1454    use crate::iterates_vector::IteratesVector;
1455    use crate::line_search::filter_acceptor::FilterLsAcceptor;
1456    use pounce_common::types::Index;
1457    use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
1458    use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
1459    use pounce_linalg::{Matrix, SymMatrix, Vector};
1460    use std::rc::Rc;
1461
1462    fn dense(n: i32, vals: &[Number]) -> Rc<dyn Vector> {
1463        let mut v = DenseVectorSpace::new(n).make_new_dense();
1464        v.set(0.0);
1465        if !vals.is_empty() {
1466            v.values_mut().copy_from_slice(vals);
1467        }
1468        Rc::new(v)
1469    }
1470
1471    fn dvec(vals: &[Number]) -> DenseVector {
1472        let mut v = DenseVectorSpace::new(vals.len() as Index).make_new_dense();
1473        v.set(0.0);
1474        if !vals.is_empty() {
1475            v.values_mut().copy_from_slice(vals);
1476        }
1477        v
1478    }
1479
1480    /// Minimal NLP for the F4 watchdog test: one variable `x[0] >= 0`,
1481    /// no constraints. `f(x) = x[0]^2`. The only finite bound is the
1482    /// lower bound on `x[0]`, so the primal fraction-to-the-boundary cap
1483    /// is governed entirely by the `x[0]` slack.
1484    struct F4MockNlp {
1485        x_l: DenseVector,
1486        x_u: DenseVector,
1487        d_l: DenseVector,
1488        d_u: DenseVector,
1489        px_l: Rc<dyn Matrix>,
1490        px_u: Rc<dyn Matrix>,
1491        pd_l: Rc<dyn Matrix>,
1492        pd_u: Rc<dyn Matrix>,
1493    }
1494
1495    impl F4MockNlp {
1496        fn new() -> Self {
1497            Self {
1498                x_l: dvec(&[0.0]),
1499                x_u: dvec(&[]),
1500                d_l: dvec(&[]),
1501                d_u: dvec(&[]),
1502                // P_L lifts the single lower-bounded var (col 0) into x[0].
1503                px_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1504                    1,
1505                    1,
1506                    &[0],
1507                    0,
1508                ))),
1509                px_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1510                    1,
1511                    0,
1512                    &[],
1513                    0,
1514                ))),
1515                pd_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1516                    0,
1517                    0,
1518                    &[],
1519                    0,
1520                ))),
1521                pd_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1522                    0,
1523                    0,
1524                    &[],
1525                    0,
1526                ))),
1527            }
1528        }
1529    }
1530
1531    impl Nlp for F4MockNlp {
1532        fn n(&self) -> Index {
1533            1
1534        }
1535        fn m_eq(&self) -> Index {
1536            0
1537        }
1538        fn m_ineq(&self) -> Index {
1539            0
1540        }
1541        fn eval_f(&mut self, x: &dyn Vector) -> Number {
1542            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1543            xx.values()[0] * xx.values()[0]
1544        }
1545        fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1546            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1547            let gg = g.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1548            gg.values_mut()[0] = 2.0 * xx.values()[0];
1549        }
1550        fn eval_c(&mut self, _x: &dyn Vector, _c: &mut dyn Vector) {}
1551        fn eval_d(&mut self, _x: &dyn Vector, _d: &mut dyn Vector) {}
1552        fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1553            unimplemented!("no equality constraints in the F4 watchdog fixture")
1554        }
1555        fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1556            unimplemented!("no inequality constraints in the F4 watchdog fixture")
1557        }
1558        fn eval_h(
1559            &mut self,
1560            _x: &dyn Vector,
1561            _obj_factor: Number,
1562            _y_c: &dyn Vector,
1563            _y_d: &dyn Vector,
1564        ) -> Rc<dyn SymMatrix> {
1565            unimplemented!("Hessian not exercised by the line search")
1566        }
1567    }
1568
1569    impl IpoptNlp for F4MockNlp {
1570        fn x_l(&self) -> &dyn Vector {
1571            &self.x_l
1572        }
1573        fn x_u(&self) -> &dyn Vector {
1574            &self.x_u
1575        }
1576        fn d_l(&self) -> &dyn Vector {
1577            &self.d_l
1578        }
1579        fn d_u(&self) -> &dyn Vector {
1580            &self.d_u
1581        }
1582        fn px_l(&self) -> Rc<dyn Matrix> {
1583            self.px_l.clone()
1584        }
1585        fn px_u(&self) -> Rc<dyn Matrix> {
1586            self.px_u.clone()
1587        }
1588        fn pd_l(&self) -> Rc<dyn Matrix> {
1589            self.pd_l.clone()
1590        }
1591        fn pd_u(&self) -> Rc<dyn Matrix> {
1592            self.pd_u.clone()
1593        }
1594    }
1595
1596    /// Acceptor that accepts the first trial unconditionally and records
1597    /// the primal step it was offered — lets the test read back the
1598    /// alpha the StopWatchDog retry started from.
1599    struct RecordingAcceptor {
1600        first_alpha: Rc<RefCell<Option<Number>>>,
1601    }
1602
1603    impl BacktrackingLsAcceptor for RecordingAcceptor {
1604        fn reset(&mut self) {}
1605        fn check_trial_point(
1606            &mut self,
1607            alpha_primal: Number,
1608            _theta: Number,
1609            _phi: Number,
1610            _d_phi: Number,
1611            _theta_trial: Number,
1612            _phi_trial: Number,
1613        ) -> AcceptDecision {
1614            let mut slot = self.first_alpha.borrow_mut();
1615            if slot.is_none() {
1616                *slot = Some(alpha_primal);
1617            }
1618            AcceptDecision::Accept
1619        }
1620    }
1621
1622    fn empty() -> Rc<dyn Vector> {
1623        dense(0, &[])
1624    }
1625
1626    /// F4 (L7 reopen): on the StopWatchDog revert, the alpha-loop retry
1627    /// must restart from the fraction-to-the-boundary cap of the
1628    /// *snapshot* direction at the *reverted* iterate — NOT the failed
1629    /// direction's cap. Pre-fix `handle_watchdog_failure` reused
1630    /// `alpha_init` (the failed direction's cap); this test pins the
1631    /// retry's first trial alpha to the recomputed snapshot cap.
1632    #[test]
1633    fn stop_watchdog_retry_recomputes_ftb_cap_from_snapshot_direction() {
1634        let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(F4MockNlp::new()));
1635        let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1636
1637        // Snapshot iterate: x = 2 (so the x[0] slack is 2), z_L = 0.5.
1638        let snap = IteratesVector::new(
1639            dense(1, &[2.0]),
1640            empty(),
1641            empty(),
1642            empty(),
1643            dense(1, &[0.5]),
1644            empty(),
1645            empty(),
1646            empty(),
1647        );
1648        {
1649            let mut d = data.borrow_mut();
1650            d.curr_mu = 0.1;
1651            d.curr_tau = 1.0;
1652            d.set_curr(snap.clone());
1653        }
1654        let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1655            data.clone(),
1656            nlp,
1657        )));
1658
1659        // Snapshot search direction: Δx = -4. At x = 2 with τ = 1 the
1660        // fraction-to-the-boundary cap is τ·s/|Δx| = 1·2/4 = 0.5.
1661        let snap_delta = IteratesVector::new(
1662            dense(1, &[-4.0]),
1663            empty(),
1664            empty(),
1665            empty(),
1666            dense(1, &[0.0]),
1667            empty(),
1668            empty(),
1669            empty(),
1670        );
1671
1672        let recorded = Rc::new(RefCell::new(None));
1673        let mut bls = BacktrackingLineSearch::new(Box::new(RecordingAcceptor {
1674            first_alpha: recorded.clone(),
1675        }));
1676
1677        // Arm the watchdog at the snapshot and put it one trial over the
1678        // cap, so the next failure triggers StopWatchDog (revert + retry).
1679        bls.in_watchdog = true;
1680        bls.watchdog_iterate = Some(snap.clone());
1681        bls.watchdog_delta = Some(snap_delta);
1682        bls.watchdog_trial_iter = bls.watchdog_trial_iter_max;
1683
1684        let outcome = bls.handle_watchdog_failure(
1685            &data, &cq, /*alpha_dual*/ 1.0, None, /*n_steps*/ 0, /*last_alpha*/ 1.0,
1686            /*evaluation_error*/ false,
1687        );
1688        assert_eq!(outcome, Outcome::Accepted);
1689
1690        // skip_first halves the recomputed cap: 0.5 × alpha_red_factor
1691        // (0.5) = 0.25. The failed direction's cap would differ.
1692        let a = recorded
1693            .borrow()
1694            .expect("acceptor must have seen at least one trial");
1695        assert!(
1696            (a - 0.25).abs() < 1e-12,
1697            "retry first alpha = {a}, expected 0.25 (snapshot FTB cap 0.5 × red 0.5)"
1698        );
1699    }
1700
1701    /// pounce#242: an already-crossed shared [`Deadline`] on `data` makes
1702    /// the alpha loop bail on its very first trial with `Outcome::Deadline`
1703    /// — before staging or evaluating any trial point — so the main loop
1704    /// can stop the solve at per-trial granularity while `data.curr`
1705    /// (untouched) remains the best iterate.
1706    #[test]
1707    fn deadline_short_circuits_the_alpha_loop() {
1708        let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(F4MockNlp::new()));
1709        let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1710        let curr = IteratesVector::new(
1711            dense(1, &[2.0]),
1712            empty(),
1713            empty(),
1714            empty(),
1715            dense(1, &[0.5]),
1716            empty(),
1717            empty(),
1718            empty(),
1719        );
1720        {
1721            let mut d = data.borrow_mut();
1722            d.curr_mu = 0.1;
1723            d.curr_tau = 1.0;
1724            d.set_curr(curr.clone());
1725            // Zero wall budget — already crossed by the time the loop runs.
1726            d.deadline = Some(pounce_common::timing::Deadline::new(0.0, 1e6));
1727        }
1728        let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1729            data.clone(),
1730            nlp.clone(),
1731        )));
1732        let delta = IteratesVector::new(
1733            dense(1, &[-1.0]),
1734            empty(),
1735            empty(),
1736            empty(),
1737            dense(1, &[0.0]),
1738            empty(),
1739            empty(),
1740            empty(),
1741        );
1742        let mut bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1743        let outcome = bls.find_acceptable_trial_point(
1744            &data,
1745            &cq,
1746            &delta,
1747            /*alpha_init*/ 1.0,
1748            /*alpha_dual*/ 1.0,
1749            Some(&nlp),
1750            None,
1751        );
1752        assert_eq!(outcome, Outcome::Deadline);
1753        // No trial was staged/promoted — curr is still the best iterate.
1754        assert!(data.borrow().trial.is_none());
1755    }
1756
1757    fn iv_from(x: &[Number], s: &[Number]) -> IteratesVector {
1758        IteratesVector::new(
1759            dense(x.len() as i32, x),
1760            dense(s.len() as i32, s),
1761            dense(0, &[]),
1762            dense(0, &[]),
1763            dense(0, &[]),
1764            dense(0, &[]),
1765            dense(0, &[]),
1766            dense(0, &[]),
1767        )
1768    }
1769
1770    #[test]
1771    fn driver_constructs_with_defaults() {
1772        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1773        assert_eq!(bls.alpha_red_factor, 0.5);
1774        assert_eq!(bls.alpha_red_factor_min, 0.05);
1775        assert_eq!(bls.max_soc, 4);
1776    }
1777
1778    #[test]
1779    fn scaled_step_writes_curr_plus_alpha_delta() {
1780        // curr.x = (0,0), delta.x = (1,1) → at alpha=0.5, trial.x = (0.5, 0.5).
1781        let curr = iv_from(&[0.0, 0.0], &[0.0]);
1782        let delta = iv_from(&[1.0, 1.0], &[2.0]);
1783        let trial = scaled_step(&curr, &delta, 0.5, 0.5, 0.5);
1784        let xv = trial
1785            .x
1786            .as_any()
1787            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1788            .unwrap()
1789            .values()
1790            .to_vec();
1791        assert_eq!(xv, vec![0.5, 0.5]);
1792        let sv = trial
1793            .s
1794            .as_any()
1795            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1796            .unwrap()
1797            .values()
1798            .to_vec();
1799        assert_eq!(sv, vec![1.0]); // 0.0 + 0.5 * 2.0
1800    }
1801
1802    #[test]
1803    fn outcome_variants_are_distinct() {
1804        assert_ne!(Outcome::Accepted, Outcome::Failed);
1805        assert_ne!(Outcome::Accepted, Outcome::TinyStep);
1806        assert_ne!(Outcome::Failed, Outcome::TinyStep);
1807    }
1808
1809    #[test]
1810    fn watchdog_state_starts_inactive() {
1811        // Mirror upstream `IpBacktrackingLineSearch::InitializeImpl`
1812        // (`IpBacktrackingLineSearch.cpp:240-249`): the watchdog is
1813        // inactive at construction and `last_mu_` is initialised to
1814        // a sentinel `-1` so the first iteration's mu always
1815        // triggers the reset branch (which is harmless when the
1816        // watchdog was never armed).
1817        let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1818        assert!(!bls.in_watchdog());
1819        assert_eq!(bls.watchdog_shortened_iter(), 0);
1820        assert!(bls.last_mu < 0.0);
1821        assert_eq!(bls.watchdog_shortened_iter_trigger, 10);
1822        assert_eq!(bls.watchdog_trial_iter_max, 3);
1823    }
1824
1825    #[test]
1826    fn restoration_resets_the_shortened_iter_counter() {
1827        // Port check for `IpBacktrackingLineSearch.cpp:624-631`. The
1828        // shortened-iter counter is a *consecutive* count, so a
1829        // restoration episode has to zero it — otherwise runs of
1830        // shortened steps on either side of one restoration add up and
1831        // arm the watchdog where upstream would not.
1832        //
1833        // The numbers here are steenbrf's (gh #524): five shortened
1834        // steps, restoration, five more. Without the reset that is 10 —
1835        // exactly `watchdog_shortened_iter_trigger` — and the watchdog
1836        // arms, burns its three trial iterations, reverts, and the line
1837        // search collapses to alpha ~1e-08. With it the counter tops
1838        // out at 5 and the solve converges.
1839        let mut bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1840        bls.watchdog_shortened_iter = 5;
1841        bls.in_soft_resto_phase = true;
1842        bls.soft_resto_counter = 4;
1843
1844        bls.reset_after_restoration();
1845
1846        assert_eq!(bls.watchdog_shortened_iter, 0);
1847        assert!(!bls.in_soft_resto_phase);
1848        assert_eq!(bls.soft_resto_counter, 0);
1849
1850        // Five more shortened steps after the restoration stay clear of
1851        // the trigger, which is the whole point.
1852        bls.watchdog_shortened_iter += 5;
1853        assert!(bls.watchdog_shortened_iter < bls.watchdog_shortened_iter_trigger);
1854    }
1855
1856    #[test]
1857    fn alpha_result_failed_carries_n_steps_and_last_alpha() {
1858        // Sanity check on the internal AlphaResult enum: the watchdog
1859        // wrapper relies on `Failed { n_steps, last_alpha }` to stamp
1860        // the info-* fields when handing off to restoration.
1861        let r = AlphaResult::Failed {
1862            n_steps: 7,
1863            last_alpha: 1e-6,
1864            evaluation_error: false,
1865        };
1866        match r {
1867            AlphaResult::Failed {
1868                n_steps,
1869                last_alpha,
1870                evaluation_error,
1871            } => {
1872                assert_eq!(n_steps, 7);
1873                assert!((last_alpha - 1e-6).abs() < 1e-20);
1874                assert!(!evaluation_error);
1875            }
1876            _ => unreachable!(),
1877        }
1878    }
1879
1880    // ---------------------------------------------------- gh#818: next_alpha
1881
1882    fn ls_for_next_alpha(red: Number, red_min: Number) -> BacktrackingLineSearch {
1883        let mut bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::default()));
1884        bls.alpha_red_factor = red;
1885        bls.alpha_red_factor_min = red_min;
1886        bls
1887    }
1888
1889    /// The interpolated step is what the model says, when the model is
1890    /// inside the safeguards. `φ(α) = 1 − α + 50α²` has `φ(0) = 1`,
1891    /// `φ'(0) = −1` and minimizer `1/100`; at the rejected `α = 1`,
1892    /// `φ(1) = 50`, so the fit is exact and `next_alpha` must return
1893    /// `0.01` — a 100× reduction the fixed factor would have needed
1894    /// seven halvings to reach.
1895    #[test]
1896    fn next_alpha_jumps_to_the_interpolated_minimizer() {
1897        let bls = ls_for_next_alpha(0.5, 1e-4);
1898        let a = bls.next_alpha(1.0, 1.0, -1.0, 50.0);
1899        assert!((a - 0.01).abs() < 1e-12, "got {a}");
1900    }
1901
1902    /// Never slower than upstream: the result is capped at
1903    /// `alpha_red_factor · alpha`, so a `φ` whose minimizer sits above
1904    /// the fixed step still contracts at the fixed rate. Without this
1905    /// cap the `alpha < alpha_min_eff` bail could be pushed arbitrarily
1906    /// far out, turning a bounded backtracking sweep into
1907    /// `max_trials` evaluations.
1908    #[test]
1909    fn next_alpha_is_never_slower_than_the_fixed_factor() {
1910        let bls = ls_for_next_alpha(0.5, 1e-4);
1911        // φ(1) barely above the tangent line ⇒ interpolated minimizer
1912        // near 1, far above 0.5.
1913        let a = bls.next_alpha(1.0, 1.0, -1.0, 0.001);
1914        assert_eq!(a, 0.5, "must clamp up to alpha_red_factor * alpha");
1915    }
1916
1917    /// Never a collapse: floored at `alpha_red_factor_min · alpha`, so
1918    /// one badly-shaped `φ` cannot drop α to noise in a single trial and
1919    /// step over an acceptable interval.
1920    #[test]
1921    fn next_alpha_is_floored_by_alpha_red_factor_min() {
1922        let bls = ls_for_next_alpha(0.5, 0.05);
1923        // Minimizer at 1e-6; the floor holds it at 0.05.
1924        let a = bls.next_alpha(1.0, 1.0, -1.0, 5e5);
1925        assert!((a - 0.05).abs() < 1e-15, "got {a}");
1926    }
1927
1928    /// `alpha_red_factor_min == alpha_red_factor` collapses the clamp,
1929    /// which is the documented way to restore upstream's fixed
1930    /// geometric sequence — and the default the builder installs on the
1931    /// exact-Hessian path.
1932    #[test]
1933    fn next_alpha_degenerates_to_the_fixed_factor_when_the_clamp_is_closed() {
1934        let bls = ls_for_next_alpha(0.5, 0.5);
1935        for phi_trial in [0.001, 2.0, 50.0, 5e5] {
1936            assert_eq!(bls.next_alpha(1.0, 1.0, -1.0, phi_trial), 0.5);
1937        }
1938    }
1939
1940    /// The two safeguards can be set in either order, and an inverted
1941    /// pair must not take the process down. `f64::clamp` panics on
1942    /// `min > max`, and the pair inverts on one legal option: the
1943    /// limited-memory default installs `alpha_red_factor_min = 0.05`
1944    /// while the registry accepts `alpha_red_factor` anywhere in
1945    /// (0, 1), so `alpha_red_factor 0.01` alone used to abort the solve
1946    /// mid-iteration (measured on `deb7` at iteration 16). The cap wins
1947    /// the tie, which also collapses the clamp — an inverted pair gets
1948    /// upstream's fixed sequence, the same thing a closed clamp gives.
1949    #[test]
1950    fn next_alpha_survives_an_inverted_safeguard_pair() {
1951        // Floor above the cap, both legal on their own.
1952        let bls = ls_for_next_alpha(0.01, 0.05);
1953        for phi_trial in [0.001, 2.0, 50.0, 5e5] {
1954            let a = bls.next_alpha(1.0, 1.0, -1.0, phi_trial);
1955            assert_eq!(a, 0.01, "inverted pair must fall back to the cap");
1956        }
1957        // And the same the other way about, where the clamp is ordinary.
1958        let bls = ls_for_next_alpha(0.5, 0.05);
1959        assert!((bls.next_alpha(1.0, 1.0, -1.0, 5e5) - 0.05).abs() < 1e-15);
1960    }
1961
1962    /// Every case in which the quadratic fit is not defined falls back
1963    /// to the fixed factor rather than producing a NaN, a negative step,
1964    /// or an increase. A NaN α here would be silent: the
1965    /// `alpha < alpha_min_eff` comparison is false for NaN, so the loop
1966    /// would keep staging trial points at a NaN step until `max_trials`.
1967    #[test]
1968    fn next_alpha_falls_back_on_every_undefined_fit() {
1969        let bls = ls_for_next_alpha(0.5, 0.05);
1970        // Non-descent direction: no positive minimizer.
1971        assert_eq!(bls.next_alpha(1.0, 1.0, 0.0, 2.0), 0.5);
1972        assert_eq!(bls.next_alpha(1.0, 1.0, 1.0, 2.0), 0.5);
1973        assert_eq!(bls.next_alpha(1.0, 1.0, Number::NAN, 2.0), 0.5);
1974        // Non-finite / non-finite-from φ.
1975        assert_eq!(bls.next_alpha(1.0, 1.0, -1.0, Number::INFINITY), 0.5);
1976        assert_eq!(bls.next_alpha(1.0, 1.0, -1.0, Number::NAN), 0.5);
1977        assert_eq!(bls.next_alpha(1.0, Number::NAN, -1.0, 2.0), 0.5);
1978        // φ(α) at or below the tangent line ⇒ denominator ≤ 0, the fit
1979        // is concave and its stationary point is a maximum. (The
1980        // acceptor rejected this α for a filter reason, not an Armijo
1981        // one, so it is reachable.)
1982        assert_eq!(bls.next_alpha(1.0, 1.0, -1.0, 0.0), 0.5);
1983        assert_eq!(bls.next_alpha(1.0, 1.0, -1.0, -5.0), 0.5);
1984    }
1985}