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