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