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}
47
48pub struct BacktrackingLineSearch {
49 pub acceptor: Box<dyn BacktrackingLsAcceptor>,
50 pub alpha_red_factor: Number,
51 pub max_soc: i32,
52 /// Threshold for the SOC outer-loop convergence test
53 /// `theta_trial <= kappa_soc * theta_soc_old`. Mirrors upstream's
54 /// `kappa_soc` (default 0.99).
55 pub kappa_soc: Number,
56 /// SOC RHS variant. `0` = upstream default ("old"), `1` = scaled
57 /// gradient-block variant. Both correspond to upstream's
58 /// `soc_method` option.
59 pub soc_method: i32,
60 /// Number of consecutive shortened iterations before the watchdog
61 /// procedure activates. Disabled when `<= 0`. Mirrors upstream's
62 /// `watchdog_shortened_iter_trigger` (default 10).
63 pub watchdog_shortened_iter_trigger: i32,
64 /// Maximum number of outer iterations the watchdog will accept
65 /// non-decreasing trial points before reverting to the snapshot.
66 /// Mirrors upstream's `watchdog_trial_iter_max` (default 3).
67 pub watchdog_trial_iter_max: i32,
68 /// Lower bound on α; below this we declare a tiny step (mirrors
69 /// `alpha_min_frac` flow, `IpBacktrackingLineSearch.cpp:CalculateAlphaMin`).
70 pub alpha_min: Number,
71 /// Maximum trial-iteration cap before declaring failure.
72 pub max_trials: i32,
73
74 // ---- Watchdog state (port of `IpBacktrackingLineSearch.{hpp,cpp}`'s
75 // `in_watchdog_`, `watchdog_iterate_`, `watchdog_delta_`,
76 // `watchdog_alpha_primal_test_`, `watchdog_trial_iter_`,
77 // `watchdog_shortened_iter_`, `last_mu_`).
78 //
79 // Watchdog mechanism: after `watchdog_shortened_iter_trigger`
80 // consecutive shortened (n_steps > 0) accepts, we snapshot the
81 // current iterate `(curr, delta, theta, phi, d_phi)` and enter
82 // watchdog mode. While in watchdog: the acceptor's reference
83 // values are FROZEN to the snapshot for up to
84 // `watchdog_trial_iter_max` outer iterations. Each iteration's
85 // alpha-loop runs against the frozen reference; if it accepts,
86 // watchdog terminates with success ("W"). If it rejects, we
87 // accept the last trial anyway (info char 'w') and let the next
88 // outer iteration try again. If `watchdog_trial_iter_max` outer
89 // iterations all reject, we revert to the snapshot and re-run
90 // the alpha-loop on the saved `delta` with `skip_first=true`.
91 /// True iff currently inside a watchdog window.
92 in_watchdog: bool,
93 /// Snapshot of the iterate at watchdog activation.
94 watchdog_iterate: Option<IteratesVector>,
95 /// Snapshot of the search direction at watchdog activation.
96 watchdog_delta: Option<IteratesVector>,
97 /// Snapshot of `primal_frac_to_the_bound(τ, δ)` at watchdog
98 /// activation. Currently unused inside the alpha loop (pounce's
99 /// driver passes `alpha_init` directly), but stored for parity
100 /// with upstream's iter-by-iter trace.
101 #[allow(dead_code)]
102 watchdog_alpha_primal_test: Number,
103 /// Number of outer iterations elapsed since watchdog activation.
104 watchdog_trial_iter: i32,
105 /// Number of consecutive shortened (n_steps > 0) accepts.
106 /// Reset on a full step (n_steps == 0), on mu change, on watchdog
107 /// success, and on watchdog stop-with-revert.
108 watchdog_shortened_iter: i32,
109 /// `mu` at the previous outer iteration. A change clears the
110 /// watchdog state (`IpBacktrackingLineSearch.cpp:259-270`).
111 last_mu: Number,
112 /// Frozen reference theta at watchdog activation.
113 watchdog_theta: Number,
114 /// Frozen reference phi at watchdog activation.
115 watchdog_phi: Number,
116 /// Frozen reference d_phi at watchdog activation.
117 watchdog_d_phi: Number,
118
119 // ---- Soft restoration phase (port of `IpBacktrackingLineSearch`'s
120 // `in_soft_resto_phase_`, `soft_resto_counter_`).
121 //
122 // When the regular filter line search fails, before handing off to
123 // the full (sub-NLP) restoration phase, the driver tries a single
124 // damped primal-dual step along the *same* search direction. The
125 // step is damped only by the fraction-to-the-boundary rule and is
126 // accepted if it either satisfies the original filter criterion
127 // ('S' — leave soft resto) or merely reduces the primal-dual KKT
128 // system error by `soft_resto_pderror_reduction_factor` ('s' —
129 // stay in soft resto). Subsequent outer iterations keep taking
130 // soft-resto steps until the original criterion is met, the step
131 // is rejected, or `max_soft_resto_iters` consecutive iterations
132 // elapse — any of which drops through to full restoration.
133 /// Required relative reduction in the primal-dual system error for
134 /// a soft-resto step to be accepted. `0` disables soft restoration.
135 /// Mirrors upstream `soft_resto_pderror_reduction_factor`
136 /// (default `1 - 1e-4`).
137 pub soft_resto_pderror_reduction_factor: Number,
138 /// Cap on consecutive soft-resto iterations before full
139 /// restoration is forced. Mirrors upstream `max_soft_resto_iters`
140 /// (default 10).
141 pub max_soft_resto_iters: i32,
142 /// True iff the driver is currently inside the soft-resto phase.
143 in_soft_resto_phase: bool,
144 /// Count of consecutive soft-resto iterations taken so far.
145 soft_resto_counter: i32,
146}
147
148/// Internal alpha-loop outcome. The watchdog wrapper translates this
149/// into the public [`Outcome`] after applying its state machine.
150enum AlphaResult {
151 /// Trial accepted at `alpha_used` after `n_steps` reductions.
152 Accepted { n_steps: i32 },
153 /// α dropped below `alpha_min_eff` ⇒ tiny step. `last_alpha` is
154 /// the smallest α actually evaluated; `n_steps` is the number of
155 /// reductions performed.
156 TinyStep { n_steps: i32, last_alpha: Number },
157 /// `max_trials` exhausted without acceptance. The last attempted
158 /// trial iterate is left in `data.trial` so the watchdog
159 /// "accept-anyway" path can promote it.
160 ///
161 /// `evaluation_error` flags that the last attempted trial produced
162 /// a non-finite `theta_trial`/`phi_trial` — mirrors upstream's
163 /// `evaluation_error` tracked from `IpoptNLP::Eval_Error`
164 /// (`IpBacktrackingLineSearch.cpp:776-784`). The watchdog handler
165 /// must treat this as a forced StopWatchDog
166 /// (`IpBacktrackingLineSearch.cpp:493`) — accepting a non-finite
167 /// iterate via the 'w' branch propagates NaN/Inf into the next
168 /// outer iter (observed on PFIT3 iter 53: inf_pr=7.87e305 from a
169 /// 'w'-accepted trial; on PFIT4 iter 31: inf_pr=1.01e11).
170 Failed {
171 n_steps: i32,
172 last_alpha: Number,
173 evaluation_error: bool,
174 },
175}
176
177impl BacktrackingLineSearch {
178 pub fn new(acceptor: Box<dyn BacktrackingLsAcceptor>) -> Self {
179 Self {
180 acceptor,
181 alpha_red_factor: 0.5,
182 max_soc: 4,
183 kappa_soc: 0.99,
184 soc_method: 0,
185 watchdog_shortened_iter_trigger: 10,
186 watchdog_trial_iter_max: 3,
187 alpha_min: 1e-12,
188 max_trials: 50,
189 in_watchdog: false,
190 watchdog_iterate: None,
191 watchdog_delta: None,
192 watchdog_alpha_primal_test: 0.0,
193 watchdog_trial_iter: 0,
194 watchdog_shortened_iter: 0,
195 last_mu: -1.0,
196 watchdog_theta: 0.0,
197 watchdog_phi: 0.0,
198 watchdog_d_phi: 0.0,
199 soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
200 max_soft_resto_iters: 10,
201 in_soft_resto_phase: false,
202 soft_resto_counter: 0,
203 }
204 }
205
206 /// Test-only accessor for the watchdog active flag.
207 #[cfg(test)]
208 pub(crate) fn in_watchdog(&self) -> bool {
209 self.in_watchdog
210 }
211
212 /// Test-only accessor for the shortened-iter counter.
213 #[cfg(test)]
214 pub(crate) fn watchdog_shortened_iter(&self) -> i32 {
215 self.watchdog_shortened_iter
216 }
217
218 pub fn acceptor(&self) -> &dyn BacktrackingLsAcceptor {
219 &*self.acceptor
220 }
221
222 pub fn acceptor_mut(&mut self) -> &mut dyn BacktrackingLsAcceptor {
223 &mut *self.acceptor
224 }
225
226 /// Reset the acceptor state at the start of a new outer iteration.
227 pub fn reset(&mut self) {
228 self.acceptor.reset();
229 }
230
231 /// Public line-search entry point. Wraps the regular filter line
232 /// search ([`Self::run_filter_line_search`]) with the soft
233 /// restoration phase — port of the `in_soft_resto_phase_` state
234 /// machine in `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
235 /// (`IpBacktrackingLineSearch.cpp:439-465` for the in-phase
236 /// continuation, `:528-556` for entering the phase).
237 ///
238 /// Outcomes:
239 /// - `Accepted`: a trial point is in `data.trial` — either a
240 /// regular filter/watchdog step or a soft-resto step (info char
241 /// 's' = stay in soft resto, 'S' = step also satisfies the
242 /// original filter so soft resto is left).
243 /// - `TinyStep` / `Failed`: neither the regular line search nor a
244 /// soft-resto step could make progress; the caller hands off to
245 /// the full restoration phase.
246 #[allow(clippy::too_many_arguments)]
247 pub fn find_acceptable_trial_point(
248 &mut self,
249 data: &IpoptDataHandle,
250 cq: &IpoptCqHandle,
251 delta: &IteratesVector,
252 alpha_init: Number,
253 alpha_dual: Number,
254 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
255 search_dir: Option<&mut PdSearchDirCalc>,
256 ) -> Outcome {
257 // ---- Soft-resto continuation. Already inside the phase: bump
258 // the counter, bail to full restoration once it exceeds
259 // `max_soft_resto_iters`, otherwise take another damped
260 // primal-dual step along the caller's `delta`
261 // (`IpBacktrackingLineSearch.cpp:439-465`).
262 if self.in_soft_resto_phase {
263 self.soft_resto_counter += 1;
264 if self.soft_resto_counter > self.max_soft_resto_iters {
265 self.in_soft_resto_phase = false;
266 self.soft_resto_counter = 0;
267 return self.fail_to_restoration(data);
268 }
269 // Per-outer-iteration acceptor hook (no-op for the filter
270 // acceptor; the penalty acceptor caches its reference here).
271 self.acceptor.init_this_line_search(data, cq, delta);
272 return match self.try_soft_resto_step(data, cq, delta) {
273 Some(satisfies_original) => {
274 if satisfies_original {
275 self.in_soft_resto_phase = false;
276 self.soft_resto_counter = 0;
277 data.borrow_mut().info_alpha_primal_char = 'S';
278 } else {
279 data.borrow_mut().info_alpha_primal_char = 's';
280 }
281 Outcome::Accepted
282 }
283 None => {
284 self.in_soft_resto_phase = false;
285 self.soft_resto_counter = 0;
286 self.fail_to_restoration(data)
287 }
288 };
289 }
290
291 // ---- Regular filter line search (watchdog + alpha loop).
292 let outcome =
293 self.run_filter_line_search(data, cq, delta, alpha_init, alpha_dual, nlp, search_dir);
294 if outcome == Outcome::Accepted {
295 return Outcome::Accepted;
296 }
297
298 // ---- Regular line search failed. Before the (expensive) full
299 // restoration sub-NLP, try to *enter* the soft restoration
300 // phase with one damped primal-dual step
301 // (`IpBacktrackingLineSearch.cpp:528-556`). `prepare_resto_phase_start`
302 // augments the outer filter with the entry envelope — mirrors
303 // upstream's `acceptor_->PrepareRestoPhaseStart()` at line 537.
304 let reference_theta = cq.borrow().curr_constraint_violation();
305 let reference_barr = cq.borrow().curr_barrier_obj();
306 self.acceptor
307 .prepare_resto_phase_start(reference_theta, reference_barr);
308 match self.try_soft_resto_step(data, cq, delta) {
309 Some(satisfies_original) => {
310 if satisfies_original {
311 data.borrow_mut().info_alpha_primal_char = 'S';
312 } else {
313 self.in_soft_resto_phase = true;
314 self.soft_resto_counter = 0;
315 data.borrow_mut().info_alpha_primal_char = 's';
316 }
317 Outcome::Accepted
318 }
319 // Soft resto could not help — fall through to full
320 // restoration with the original failure outcome. The
321 // caller's `invoke_restoration` re-runs
322 // `prepare_resto_phase_start`; the duplicate filter
323 // augmentation is idempotent (same envelope).
324 None => outcome,
325 }
326 }
327
328 /// Stamp the info fields for a hand-off to the full restoration
329 /// phase and return `Outcome::Failed`. Used when the soft
330 /// restoration phase exhausts its iteration budget or its step is
331 /// rejected mid-phase.
332 fn fail_to_restoration(&self, data: &IpoptDataHandle) -> Outcome {
333 let mut d = data.borrow_mut();
334 d.trial = None;
335 d.info_alpha_primal = 0.0;
336 d.info_alpha_dual = 0.0;
337 d.info_alpha_primal_char = 'R';
338 d.info_ls_count = 0;
339 Outcome::Failed
340 }
341
342 /// Attempt a single damped primal-dual step for the soft
343 /// restoration phase — port of
344 /// `BacktrackingLineSearch::TrySoftRestoStep`
345 /// (`IpBacktrackingLineSearch.cpp:1112-1217`). The step along
346 /// `delta` is damped only by the fraction-to-the-boundary rule,
347 /// with an identical step length for primal and dual variables.
348 ///
349 /// Returns:
350 /// - `Some(true)` — trial accepted *and* it satisfies the
351 /// original filter criterion ⇒ caller leaves soft resto ('S').
352 /// - `Some(false)` — trial accepted only on the primal-dual error
353 /// reduction test ⇒ caller stays in soft resto ('s').
354 /// - `None` — trial rejected (or soft resto disabled / a
355 /// non-finite evaluation) ⇒ caller falls through to the full
356 /// restoration phase.
357 ///
358 /// On a `Some(_)` return the accepted trial is left in `data.trial`
359 /// and the numeric `info_*` fields are stamped; the caller stamps
360 /// `info_alpha_primal_char`.
361 fn try_soft_resto_step(
362 &mut self,
363 data: &IpoptDataHandle,
364 cq: &IpoptCqHandle,
365 delta: &IteratesVector,
366 ) -> Option<bool> {
367 // Soft restoration is disabled when the reduction factor is
368 // zero (`IpBacktrackingLineSearch.cpp:1124`).
369 if self.soft_resto_pderror_reduction_factor == 0.0 {
370 return None;
371 }
372 let curr = data.borrow().curr.clone()?;
373 let tau = data.borrow().curr_tau;
374
375 // Identical step length for primal and dual variables, damped
376 // only by the fraction-to-the-boundary rule
377 // (`IpBacktrackingLineSearch.cpp:1135-1140`).
378 let alpha = {
379 let cq_ref = cq.borrow();
380 cq_ref
381 .aff_step_alpha_primal_max(delta, tau)
382 .min(cq_ref.aff_step_alpha_dual_max(delta, tau))
383 };
384
385 let trial_iv = scaled_step(&curr, delta, alpha, alpha);
386 data.borrow_mut().set_trial(trial_iv);
387
388 let theta_trial = cq.borrow().trial_constraint_violation();
389 let phi_trial = cq.borrow().trial_barrier_obj();
390 if !theta_trial.is_finite() || !phi_trial.is_finite() {
391 // Upstream retries up to three times on `Eval_Error`; the
392 // step length is fixed, so a non-finite eval here is
393 // deterministic — treat it as a rejection.
394 return None;
395 }
396
397 let theta = cq.borrow().curr_constraint_violation();
398 let phi = cq.borrow().curr_barrier_obj();
399 let d_phi = self.compute_d_phi(cq, delta);
400
401 // First test: is the trial acceptable to the *original*
402 // backtracking globalization? Upstream
403 // `acceptor_->CheckAcceptabilityOfTrialPoint(0.)`.
404 if self
405 .acceptor
406 .check_trial_point(0.0, theta, phi, d_phi, theta_trial, phi_trial)
407 == AcceptDecision::Accept
408 {
409 let mut d = data.borrow_mut();
410 d.info_alpha_primal = alpha;
411 d.info_alpha_dual = alpha;
412 d.info_ls_count = 1;
413 return Some(true);
414 }
415
416 // Second test: sufficient reduction in the primal-dual KKT
417 // system error (`IpBacktrackingLineSearch.cpp:1184-1211`).
418 let mu = data.borrow().curr_mu;
419 let curr_pderror = cq.borrow().curr_primal_dual_system_error(mu);
420 let trial_pderror = cq.borrow().trial_primal_dual_system_error(mu);
421 if !trial_pderror.is_finite() {
422 return None;
423 }
424 if trial_pderror <= self.soft_resto_pderror_reduction_factor * curr_pderror {
425 let mut d = data.borrow_mut();
426 d.info_alpha_primal = alpha;
427 d.info_alpha_dual = alpha;
428 d.info_ls_count = 1;
429 return Some(false);
430 }
431 None
432 }
433
434 /// Drive the watchdog state machine + alpha-reduction loop.
435 /// Port of `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
436 /// (`IpBacktrackingLineSearch.cpp:252-677`) restricted to the
437 /// regular (non-soft-resto) filter-acceptor, exact-Hessian path.
438 /// The soft restoration phase is layered on top by
439 /// [`Self::find_acceptable_trial_point`].
440 ///
441 /// Outcomes:
442 /// - `Accepted`: a trial point is in `data.trial`, info fields are
443 /// stamped. The watchdog state has been advanced (success → "W",
444 /// `accept-anyway` → 'w').
445 /// - `TinyStep`: α dropped below the dynamic alpha-min before any
446 /// trial was accepted. Caller hands off to restoration.
447 /// - `Failed`: alpha-loop exhausted AND watchdog could not rescue.
448 /// Caller hands off to restoration.
449 #[allow(clippy::too_many_arguments)]
450 fn run_filter_line_search(
451 &mut self,
452 data: &IpoptDataHandle,
453 cq: &IpoptCqHandle,
454 delta: &IteratesVector,
455 alpha_init: Number,
456 alpha_dual: Number,
457 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
458 search_dir: Option<&mut PdSearchDirCalc>,
459 ) -> Outcome {
460 // ---- Watchdog: detect mu change → reset state.
461 // Mirrors `IpBacktrackingLineSearch.cpp:259-270`.
462 let curr_mu = data.borrow().curr_mu;
463 if self.last_mu < 0.0 || self.last_mu != curr_mu {
464 self.in_watchdog = false;
465 self.watchdog_iterate = None;
466 self.watchdog_delta = None;
467 self.watchdog_shortened_iter = 0;
468 self.last_mu = curr_mu;
469 }
470
471 // ---- Watchdog: maybe wake up.
472 // Mirrors `IpBacktrackingLineSearch.cpp:376-380`.
473 if !self.in_watchdog
474 && self.watchdog_shortened_iter_trigger > 0
475 && self.watchdog_shortened_iter >= self.watchdog_shortened_iter_trigger
476 {
477 self.start_watchdog(data, cq, delta);
478 }
479
480 // Per-outer-iteration acceptor hook.
481 self.acceptor.init_this_line_search(data, cq, delta);
482
483 // Decide reference (theta, phi, d_phi). Mirrors upstream's
484 // `FilterLSAcceptor::InitThisLineSearch(in_watchdog)` choice
485 // between `curr_*` and the saved `watchdog_*` snapshot.
486 let (theta, phi, d_phi) = if self.in_watchdog {
487 (self.watchdog_theta, self.watchdog_phi, self.watchdog_d_phi)
488 } else {
489 let theta = cq.borrow().curr_constraint_violation();
490 let phi = cq.borrow().curr_barrier_obj();
491 let d_phi = self.compute_d_phi(cq, delta);
492 (theta, phi, d_phi)
493 };
494
495 // Run the alpha-loop on the caller's `delta`.
496 let result = self.run_alpha_loop(
497 data, cq, delta, alpha_init, alpha_dual, nlp, search_dir, theta, phi, d_phi,
498 /*skip_first*/ false,
499 );
500
501 match result {
502 AlphaResult::Accepted { n_steps } => {
503 // Update the shortened-iter counter
504 // (`IpBacktrackingLineSearch.cpp:644-655`).
505 if n_steps == 0 {
506 self.watchdog_shortened_iter = 0;
507 } else {
508 self.watchdog_shortened_iter += 1;
509 }
510 if self.in_watchdog {
511 // Watchdog success — clear state, info char already
512 // stamped by the alpha loop's
513 // `update_for_next_iteration` call. Upstream also
514 // appends "W" to the info string here; pounce
515 // doesn't track an info string yet.
516 self.in_watchdog = false;
517 self.watchdog_iterate = None;
518 self.watchdog_delta = None;
519 self.watchdog_shortened_iter = 0;
520 }
521 Outcome::Accepted
522 }
523 AlphaResult::TinyStep {
524 n_steps,
525 last_alpha,
526 } => {
527 let mut d = data.borrow_mut();
528 d.trial = None;
529 d.info_alpha_primal = last_alpha;
530 d.info_alpha_dual = 0.0;
531 d.info_alpha_primal_char = 'R';
532 d.info_ls_count = n_steps + 1;
533 Outcome::TinyStep
534 }
535 AlphaResult::Failed {
536 n_steps,
537 last_alpha,
538 evaluation_error,
539 } => {
540 if self.in_watchdog {
541 self.handle_watchdog_failure(
542 data,
543 cq,
544 alpha_init,
545 alpha_dual,
546 nlp,
547 n_steps,
548 last_alpha,
549 evaluation_error,
550 )
551 } else {
552 // Genuine failure → restoration.
553 let mut d = data.borrow_mut();
554 d.trial = None;
555 d.info_alpha_primal = last_alpha;
556 d.info_alpha_dual = 0.0;
557 d.info_alpha_primal_char = 'R';
558 d.info_ls_count = n_steps + 1;
559 Outcome::Failed
560 }
561 }
562 }
563 }
564
565 /// Snapshot the current `(curr, delta, theta, phi, d_phi)` and
566 /// activate the watchdog. Mirrors upstream
567 /// `IpBacktrackingLineSearch::StartWatchDog`
568 /// (`IpBacktrackingLineSearch.cpp:855-869`) plus
569 /// `IpFilterLSAcceptor::StartWatchDog`
570 /// (`IpFilterLSAcceptor.cpp:506-513`) — pounce stores the
571 /// frozen reference values directly on the driver because the
572 /// acceptor is stateless w.r.t. reference values (the driver
573 /// passes them per call).
574 fn start_watchdog(
575 &mut self,
576 data: &IpoptDataHandle,
577 cq: &IpoptCqHandle,
578 delta: &IteratesVector,
579 ) {
580 let curr = data.borrow().curr.clone();
581 let Some(curr) = curr else {
582 return;
583 };
584 self.in_watchdog = true;
585 self.watchdog_iterate = Some(curr);
586 self.watchdog_delta = Some(delta.clone());
587 self.watchdog_trial_iter = 0;
588 let tau = data.borrow().curr_tau;
589 self.watchdog_alpha_primal_test = cq.borrow().aff_step_alpha_primal_max(delta, tau);
590 self.watchdog_theta = cq.borrow().curr_constraint_violation();
591 self.watchdog_phi = cq.borrow().curr_barrier_obj();
592 self.watchdog_d_phi = self.compute_d_phi(cq, delta);
593 }
594
595 /// Handle alpha-loop failure while in watchdog mode. Bumps
596 /// `watchdog_trial_iter`; if the cap is exceeded, reverts to the
597 /// snapshot (StopWatchDog) and re-runs the alpha-loop on the
598 /// saved `delta` with `skip_first=true`. Otherwise accepts the
599 /// current trial as 'w' and returns. Mirrors
600 /// `IpBacktrackingLineSearch.cpp:480-503` together with
601 /// `IpBacktrackingLineSearch.cpp:871-908`'s `StopWatchDog`.
602 fn handle_watchdog_failure(
603 &mut self,
604 data: &IpoptDataHandle,
605 cq: &IpoptCqHandle,
606 alpha_init: Number,
607 alpha_dual: Number,
608 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
609 n_steps: i32,
610 last_alpha: Number,
611 evaluation_error: bool,
612 ) -> Outcome {
613 self.watchdog_trial_iter += 1;
614 // Mirror upstream `IpBacktrackingLineSearch.cpp:493`:
615 // `if (evaluation_error || watchdog_trial_iter > max)` →
616 // StopWatchDog. A non-finite trial must NOT be promoted via
617 // the 'w' accept-anyway path; doing so propagates NaN/Inf
618 // into the next outer iter and the iterate is unrecoverable
619 // (observed on PFIT3, PFIT4).
620 if evaluation_error || self.watchdog_trial_iter > self.watchdog_trial_iter_max {
621 // StopWatchDog: revert curr to the snapshot, re-run on
622 // saved delta with `skip_first=true` (alpha starts at
623 // `alpha_init * alpha_red_factor`).
624 let snapshot_iter = self.watchdog_iterate.take();
625 let snapshot_delta = self.watchdog_delta.take();
626 self.in_watchdog = false;
627 self.watchdog_shortened_iter = 0;
628 let (Some(snap), Some(snap_delta)) = (snapshot_iter, snapshot_delta) else {
629 // Defensive — this should not happen if start_watchdog
630 // ran successfully. Fall through to genuine failure.
631 let mut d = data.borrow_mut();
632 d.trial = None;
633 d.info_alpha_primal = last_alpha;
634 d.info_alpha_dual = 0.0;
635 d.info_alpha_primal_char = 'R';
636 d.info_ls_count = n_steps + 1;
637 return Outcome::Failed;
638 };
639 {
640 let mut d = data.borrow_mut();
641 d.set_curr(snap);
642 }
643 let theta = cq.borrow().curr_constraint_violation();
644 let phi = cq.borrow().curr_barrier_obj();
645 let d_phi = self.compute_d_phi(cq, &snap_delta);
646 // SOC is disabled on the StopWatchDog retry. The original
647 // `search_dir` was consumed by the first alpha-loop call
648 // and we want a plain backtracking pass over the saved
649 // delta; mirrors upstream's behavior of not running the
650 // soc_method on the recovered search.
651 let result2 = self.run_alpha_loop(
652 data,
653 cq,
654 &snap_delta,
655 alpha_init,
656 alpha_dual,
657 nlp,
658 None,
659 theta,
660 phi,
661 d_phi,
662 /*skip_first*/ true,
663 );
664 match result2 {
665 AlphaResult::Accepted { n_steps: ns2 } => {
666 if ns2 == 0 {
667 self.watchdog_shortened_iter = 0;
668 } else {
669 self.watchdog_shortened_iter += 1;
670 }
671 Outcome::Accepted
672 }
673 AlphaResult::TinyStep {
674 n_steps: ns2,
675 last_alpha: la2,
676 } => {
677 let mut d = data.borrow_mut();
678 d.trial = None;
679 d.info_alpha_primal = la2;
680 d.info_alpha_dual = 0.0;
681 d.info_alpha_primal_char = 'R';
682 d.info_ls_count = ns2 + 1;
683 Outcome::TinyStep
684 }
685 AlphaResult::Failed {
686 n_steps: ns2,
687 last_alpha: la2,
688 evaluation_error: _,
689 } => {
690 let mut d = data.borrow_mut();
691 d.trial = None;
692 d.info_alpha_primal = la2;
693 d.info_alpha_dual = 0.0;
694 d.info_alpha_primal_char = 'R';
695 d.info_ls_count = ns2 + 1;
696 Outcome::Failed
697 }
698 }
699 } else {
700 // Accept the last attempted trial despite filter rejection
701 // — `accept-anyway` watchdog branch
702 // (`IpBacktrackingLineSearch.cpp:498-503`). The trial
703 // iterate from the final α attempt is already in
704 // `data.trial`. Crucially, we do NOT call
705 // `update_for_next_iteration`, so the filter is NOT
706 // augmented (matching upstream's char='w' branch at
707 // line 833-836 which skips `UpdateForNextIteration`).
708 let mut d = data.borrow_mut();
709 d.info_alpha_primal = last_alpha;
710 d.info_alpha_dual = alpha_dual;
711 d.info_alpha_primal_char = 'w';
712 d.info_ls_count = n_steps + 1;
713 Outcome::Accepted
714 }
715 }
716
717 /// Inner alpha-reduction loop. Tries
718 /// `alpha = alpha_init * alpha_red_factor^k` (or
719 /// `alpha_red_factor^(k+1)` when `skip_first=true`) and consults
720 /// the acceptor against the supplied reference `(theta, phi, d_phi)`.
721 /// On accept stamps the info fields and calls
722 /// `update_for_next_iteration`. On reject leaves the LAST trial in
723 /// `data.trial` so the watchdog `accept-anyway` path can promote
724 /// it.
725 #[allow(clippy::too_many_arguments)]
726 fn run_alpha_loop(
727 &mut self,
728 data: &IpoptDataHandle,
729 cq: &IpoptCqHandle,
730 delta: &IteratesVector,
731 alpha_init: Number,
732 alpha_dual: Number,
733 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
734 search_dir: Option<&mut PdSearchDirCalc>,
735 theta: Number,
736 phi: Number,
737 d_phi: Number,
738 skip_first: bool,
739 ) -> AlphaResult {
740 let curr = match data.borrow().curr.clone() {
741 Some(c) => c,
742 None => {
743 return AlphaResult::Failed {
744 n_steps: 0,
745 last_alpha: 0.0,
746 evaluation_error: false,
747 }
748 }
749 };
750
751 let mut evaluation_error = false;
752
753 let mut soc_search_dir = search_dir;
754 let (mut c_soc_buf, mut dms_soc_buf) =
755 if soc_search_dir.is_some() && nlp.is_some() && self.max_soc > 0 && !skip_first {
756 let cq_ref = cq.borrow();
757 let curr_c = cq_ref.curr_c();
758 let curr_dms = cq_ref.curr_d_minus_s();
759 let mut c_soc = curr_c.make_new();
760 c_soc.copy(&*curr_c);
761 let mut dms_soc = curr_dms.make_new();
762 dms_soc.copy(&*curr_dms);
763 (Some(c_soc), Some(dms_soc))
764 } else {
765 (None, None)
766 };
767
768 let mut alpha = if skip_first {
769 alpha_init * self.alpha_red_factor
770 } else {
771 alpha_init
772 };
773 let mut last_alpha = alpha;
774 let mut n_steps: i32 = 0;
775 let acceptor_alpha_min = self.acceptor.calc_alpha_min(d_phi, theta);
776 let alpha_min_eff = self.alpha_min.max(acceptor_alpha_min);
777
778 for trial in 0..self.max_trials {
779 if alpha < alpha_min_eff {
780 return AlphaResult::TinyStep {
781 n_steps,
782 last_alpha,
783 };
784 }
785 last_alpha = alpha;
786 n_steps = trial;
787
788 let trial_iv = scaled_step(&curr, delta, alpha, alpha_dual);
789 data.borrow_mut().set_trial(trial_iv);
790
791 let theta_trial = cq.borrow().trial_constraint_violation();
792 let phi_trial = cq.borrow().trial_barrier_obj();
793 if !theta_trial.is_finite() || !phi_trial.is_finite() {
794 // Mirror upstream `IpBacktrackingLineSearch.cpp:776-784`:
795 // a non-finite eval is treated as `Eval_Error`, sets the
796 // `evaluation_error` flag, and the alpha-loop continues
797 // to backtrack. Under watchdog, upstream breaks out
798 // immediately (line 791-794) so the watchdog handler
799 // can force StopWatchDog via line 493.
800 evaluation_error = true;
801 if self.in_watchdog {
802 return AlphaResult::Failed {
803 n_steps: trial,
804 last_alpha: alpha,
805 evaluation_error: true,
806 };
807 }
808 alpha *= self.alpha_red_factor;
809 continue;
810 }
811
812 let decision =
813 self.acceptor
814 .check_trial_point(alpha, theta, phi, d_phi, theta_trial, phi_trial);
815 if decision == AcceptDecision::Accept {
816 let mode = self
817 .acceptor
818 .update_for_next_iteration(alpha, theta, phi, d_phi, phi_trial);
819 if std::env::var_os("POUNCE_DBG_LS").is_some() {
820 let d = data.borrow();
821 eprintln!(
822 "[PN_LS] iter={} mu={:.3e} alpha={:.3e} alpha_d={:.3e} mode={} theta={:.6e} theta_trial={:.6e} phi={:.6e} phi_trial={:.6e} n_steps={}",
823 d.iter_count, d.curr_mu, alpha, alpha_dual, mode, theta, theta_trial, phi, phi_trial, trial
824 );
825 }
826 let mut d = data.borrow_mut();
827 d.info_alpha_primal = alpha;
828 d.info_alpha_dual = alpha_dual;
829 d.info_ls_count = trial + 1;
830 d.info_alpha_primal_char = mode;
831 return AlphaResult::Accepted { n_steps: trial };
832 }
833
834 // Watchdog: under upstream `IpBacktrackingLineSearch.cpp:791-794`,
835 // a failed trial inside the watchdog window breaks out of the
836 // alpha-loop immediately — alpha is NOT reduced. The trial just
837 // attempted (at the full `alpha_init`) is left in `data.trial`
838 // so `handle_watchdog_failure` can promote it via the 'w'
839 // accept-anyway branch. Without this break, pounce kept
840 // reducing alpha under watchdog and accepted the same tiny
841 // step that triggered watchdog activation in the first place,
842 // leaving the iterate stalled (observed on HATFLDFLNE: iter 11
843 // accepted α=1.22e-4 'h' instead of α=1.00 'w').
844 if self.in_watchdog {
845 return AlphaResult::Failed {
846 n_steps: trial,
847 last_alpha: alpha,
848 evaluation_error,
849 };
850 }
851
852 // SOC: only on the first non-skipped trial when constraint
853 // violation grew. Disabled when `skip_first=true` (no SOC
854 // buffers were allocated). Also disabled under watchdog (the
855 // `in_watchdog` break above pre-empts SOC, matching upstream
856 // which gates SOC after the in_watchdog break).
857 if trial == 0
858 && !skip_first
859 && self.max_soc > 0
860 && theta <= theta_trial
861 && c_soc_buf.is_some()
862 && dms_soc_buf.is_some()
863 {
864 let alpha_test = alpha;
865 let mut count_soc: i32 = 0;
866 let mut theta_soc_old: Number = 0.0;
867 let mut theta_trial_local = theta_trial;
868 let mut alpha_primal_soc = alpha;
869 let mut soc_accepted = false;
870 while count_soc < self.max_soc
871 && !soc_accepted
872 && (count_soc == 0 || theta_trial_local <= self.kappa_soc * theta_soc_old)
873 {
874 theta_soc_old = theta_trial_local;
875 {
876 let cq_ref = cq.borrow();
877 let trial_c = cq_ref.trial_c();
878 let trial_dms = cq_ref.trial_d_minus_s();
879 if let Some(c_soc) = c_soc_buf.as_mut() {
880 c_soc.scal(alpha_primal_soc);
881 c_soc.axpy(1.0, &*trial_c);
882 }
883 if let Some(dms_soc) = dms_soc_buf.as_mut() {
884 dms_soc.scal(alpha_primal_soc);
885 dms_soc.axpy(1.0, &*trial_dms);
886 }
887 }
888 let delta_soc_opt = {
889 let sd = soc_search_dir
890 .as_deref_mut()
891 .expect("SOC: search_dir is gated above");
892 let nlp_ref = nlp.expect("SOC: nlp is gated above");
893 let c_soc = c_soc_buf.as_deref().expect("SOC: c_soc_buf is gated above");
894 let dms_soc = dms_soc_buf
895 .as_deref()
896 .expect("SOC: dms_soc_buf is gated above");
897 sd.compute_soc_step(
898 data,
899 cq,
900 nlp_ref,
901 c_soc,
902 dms_soc,
903 alpha_primal_soc,
904 self.soc_method,
905 )
906 };
907 let Some(delta_soc) = delta_soc_opt else {
908 break;
909 };
910 let tau = data.borrow().curr_tau;
911 alpha_primal_soc = cq.borrow().aff_step_alpha_primal_max(&delta_soc, tau);
912 // Upstream `IpFilterLSAcceptor.cpp` sets `actual_delta =
913 // delta_soc` on an accepted SOC step: the *entire* step,
914 // primal and dual, is replaced. The dual update therefore
915 // uses the SOC step's own multiplier components — not the
916 // original `delta` — and the dual fraction-to-boundary is
917 // recomputed from `delta_soc`
918 // (`IpBacktrackingLineSearch.cpp:639`). Applying `delta`'s
919 // duals here left the accepted iterate with a primal from
920 // `delta_soc` but duals from `delta`, diverging `inf_du`
921 // from Ipopt on any `H`-flagged iteration (e.g. CRESC4).
922 let alpha_dual_soc = cq.borrow().aff_step_alpha_dual_max(&delta_soc, tau);
923 let mut trial_iv = curr.deep_copy();
924 trial_iv.x.axpy(alpha_primal_soc, &*delta_soc.x);
925 trial_iv.s.axpy(alpha_primal_soc, &*delta_soc.s);
926 trial_iv.y_c.axpy(alpha_primal_soc, &*delta_soc.y_c);
927 trial_iv.y_d.axpy(alpha_primal_soc, &*delta_soc.y_d);
928 trial_iv.z_l.axpy(alpha_dual_soc, &*delta_soc.z_l);
929 trial_iv.z_u.axpy(alpha_dual_soc, &*delta_soc.z_u);
930 trial_iv.v_l.axpy(alpha_dual_soc, &*delta_soc.v_l);
931 trial_iv.v_u.axpy(alpha_dual_soc, &*delta_soc.v_u);
932 let trial_iv = trial_iv.freeze();
933 data.borrow_mut().set_trial(trial_iv);
934 let theta_soc = cq.borrow().trial_constraint_violation();
935 let phi_soc = cq.borrow().trial_barrier_obj();
936 if !theta_soc.is_finite() || !phi_soc.is_finite() {
937 break;
938 }
939 let dec = self
940 .acceptor
941 .check_trial_point(alpha_test, theta, phi, d_phi, theta_soc, phi_soc);
942 if dec == AcceptDecision::Accept {
943 let mode = self
944 .acceptor
945 .update_for_next_iteration(alpha_test, theta, phi, d_phi, phi_soc);
946 let mut d = data.borrow_mut();
947 d.info_alpha_primal = alpha_primal_soc;
948 d.info_alpha_dual = alpha_dual_soc;
949 d.info_ls_count = trial + 1;
950 d.info_alpha_primal_char = mode.to_ascii_uppercase();
951 return AlphaResult::Accepted { n_steps: trial };
952 }
953 count_soc += 1;
954 theta_trial_local = theta_soc;
955 soc_accepted = false;
956 }
957 }
958
959 alpha *= self.alpha_red_factor;
960 }
961
962 AlphaResult::Failed {
963 n_steps,
964 last_alpha,
965 evaluation_error,
966 }
967 }
968
969 /// Directional derivative of the barrier objective along the step
970 /// `delta`: `d_phi = ∇_x φ · dx + ∇_s φ · ds`.
971 fn compute_d_phi(&self, cq: &IpoptCqHandle, delta: &IteratesVector) -> Number {
972 let cq_ref = cq.borrow();
973 let g_x = cq_ref.curr_grad_barrier_obj_x();
974 let g_s = cq_ref.curr_grad_barrier_obj_s();
975 g_x.dot(&*delta.x) + g_s.dot(&*delta.s)
976 }
977}
978
979/// `out = curr + alpha * delta` for all eight components, returned as a
980/// fresh `IteratesVector` with `Rc<dyn Vector>` slots. Mirrors
981/// `IpoptData::SetTrialBoundMultipliersFromStep` + the primal step
982/// path in upstream — both share the same scalar α here because
983/// fraction-to-the-boundary truncation has already been folded into
984/// `alpha_init` upstream.
985fn scaled_step(
986 curr: &IteratesVector,
987 delta: &IteratesVector,
988 alpha_primal: Number,
989 alpha_dual: Number,
990) -> IteratesVector {
991 let mut out = curr.make_new_zeroed();
992 out.add_one_vector(1.0, curr, 0.0); // out = curr
993 out.x.axpy(alpha_primal, &*delta.x);
994 out.s.axpy(alpha_primal, &*delta.s);
995 out.y_c.axpy(alpha_primal, &*delta.y_c);
996 out.y_d.axpy(alpha_primal, &*delta.y_d);
997 out.z_l.axpy(alpha_dual, &*delta.z_l);
998 out.z_u.axpy(alpha_dual, &*delta.z_u);
999 out.v_l.axpy(alpha_dual, &*delta.v_l);
1000 out.v_u.axpy(alpha_dual, &*delta.v_u);
1001 out.freeze()
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use crate::iterates_vector::IteratesVector;
1008 use crate::line_search::filter_acceptor::FilterLsAcceptor;
1009 use pounce_linalg::dense_vector::DenseVectorSpace;
1010 use pounce_linalg::Vector;
1011 use std::rc::Rc;
1012
1013 fn dense(n: i32, vals: &[Number]) -> Rc<dyn Vector> {
1014 let mut v = DenseVectorSpace::new(n).make_new_dense();
1015 v.set(0.0);
1016 if !vals.is_empty() {
1017 v.values_mut().copy_from_slice(vals);
1018 }
1019 Rc::new(v)
1020 }
1021
1022 fn iv_from(x: &[Number], s: &[Number]) -> IteratesVector {
1023 IteratesVector::new(
1024 dense(x.len() as i32, x),
1025 dense(s.len() as i32, s),
1026 dense(0, &[]),
1027 dense(0, &[]),
1028 dense(0, &[]),
1029 dense(0, &[]),
1030 dense(0, &[]),
1031 dense(0, &[]),
1032 )
1033 }
1034
1035 #[test]
1036 fn driver_constructs_with_defaults() {
1037 let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1038 assert_eq!(bls.alpha_red_factor, 0.5);
1039 assert_eq!(bls.max_soc, 4);
1040 }
1041
1042 #[test]
1043 fn scaled_step_writes_curr_plus_alpha_delta() {
1044 // curr.x = (0,0), delta.x = (1,1) → at alpha=0.5, trial.x = (0.5, 0.5).
1045 let curr = iv_from(&[0.0, 0.0], &[0.0]);
1046 let delta = iv_from(&[1.0, 1.0], &[2.0]);
1047 let trial = scaled_step(&curr, &delta, 0.5, 0.5);
1048 let xv = trial
1049 .x
1050 .as_any()
1051 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1052 .unwrap()
1053 .values()
1054 .to_vec();
1055 assert_eq!(xv, vec![0.5, 0.5]);
1056 let sv = trial
1057 .s
1058 .as_any()
1059 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1060 .unwrap()
1061 .values()
1062 .to_vec();
1063 assert_eq!(sv, vec![1.0]); // 0.0 + 0.5 * 2.0
1064 }
1065
1066 #[test]
1067 fn outcome_variants_are_distinct() {
1068 assert_ne!(Outcome::Accepted, Outcome::Failed);
1069 assert_ne!(Outcome::Accepted, Outcome::TinyStep);
1070 assert_ne!(Outcome::Failed, Outcome::TinyStep);
1071 }
1072
1073 #[test]
1074 fn watchdog_state_starts_inactive() {
1075 // Mirror upstream `IpBacktrackingLineSearch::InitializeImpl`
1076 // (`IpBacktrackingLineSearch.cpp:240-249`): the watchdog is
1077 // inactive at construction and `last_mu_` is initialised to
1078 // a sentinel `-1` so the first iteration's mu always
1079 // triggers the reset branch (which is harmless when the
1080 // watchdog was never armed).
1081 let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1082 assert!(!bls.in_watchdog());
1083 assert_eq!(bls.watchdog_shortened_iter(), 0);
1084 assert!(bls.last_mu < 0.0);
1085 assert_eq!(bls.watchdog_shortened_iter_trigger, 10);
1086 assert_eq!(bls.watchdog_trial_iter_max, 3);
1087 }
1088
1089 #[test]
1090 fn alpha_result_failed_carries_n_steps_and_last_alpha() {
1091 // Sanity check on the internal AlphaResult enum: the watchdog
1092 // wrapper relies on `Failed { n_steps, last_alpha }` to stamp
1093 // the info-* fields when handing off to restoration.
1094 let r = AlphaResult::Failed {
1095 n_steps: 7,
1096 last_alpha: 1e-6,
1097 evaluation_error: false,
1098 };
1099 match r {
1100 AlphaResult::Failed {
1101 n_steps,
1102 last_alpha,
1103 evaluation_error,
1104 } => {
1105 assert_eq!(n_steps, 7);
1106 assert!((last_alpha - 1e-6).abs() < 1e-20);
1107 assert!(!evaluation_error);
1108 }
1109 _ => unreachable!(),
1110 }
1111 }
1112}