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