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 /// Public line-search entry point. Wraps the regular filter line
281 /// search ([`Self::run_filter_line_search`]) with the soft
282 /// restoration phase — port of the `in_soft_resto_phase_` state
283 /// machine in `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
284 /// (`IpBacktrackingLineSearch.cpp:439-465` for the in-phase
285 /// continuation, `:528-556` for entering the phase).
286 ///
287 /// Outcomes:
288 /// - `Accepted`: a trial point is in `data.trial` — either a
289 /// regular filter/watchdog step or a soft-resto step (info char
290 /// 's' = stay in soft resto, 'S' = step also satisfies the
291 /// original filter so soft resto is left).
292 /// - `TinyStep` / `Failed`: neither the regular line search nor a
293 /// soft-resto step could make progress; the caller hands off to
294 /// the full restoration phase.
295 #[allow(clippy::too_many_arguments)]
296 pub fn find_acceptable_trial_point(
297 &mut self,
298 data: &IpoptDataHandle,
299 cq: &IpoptCqHandle,
300 delta: &IteratesVector,
301 alpha_init: Number,
302 alpha_dual: Number,
303 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
304 search_dir: Option<&mut PdSearchDirCalc>,
305 ) -> Outcome {
306 // ---- `accept_every_trial_step` short-circuit. Mirrors the
307 // unglobalized path at the top of
308 // `IpBacktrackingLineSearch::FindAcceptableTrialPoint` (when
309 // `accept_every_trial_step_` is true): no soft-resto, no
310 // watchdog, no alpha loop, no filter update — just take the
311 // FTB-truncated step (`alpha_init`, `alpha_dual` already
312 // include the fraction-to-the-boundary rule) and accept it
313 // unconditionally. Used by the Mehrotra cascade.
314 if self.accept_every_trial_step {
315 let curr = match data.borrow().curr.clone() {
316 Some(c) => c,
317 None => return Outcome::Failed,
318 };
319 let alpha_y = self.alpha_for_y.alpha_y(alpha_init, alpha_dual);
320 let trial_iv = scaled_step(&curr, delta, alpha_init, alpha_y, alpha_dual);
321 let mut d = data.borrow_mut();
322 d.set_trial(trial_iv);
323 d.info_alpha_primal = alpha_init;
324 d.info_alpha_dual = alpha_dual;
325 d.info_alpha_primal_char = ' ';
326 d.info_ls_count = 1;
327 return Outcome::Accepted;
328 }
329
330 // ---- Soft-resto continuation. Already inside the phase: bump
331 // the counter, bail to full restoration once it exceeds
332 // `max_soft_resto_iters`, otherwise take another damped
333 // primal-dual step along the caller's `delta`
334 // (`IpBacktrackingLineSearch.cpp:439-465`).
335 if self.in_soft_resto_phase {
336 self.soft_resto_counter += 1;
337 if self.soft_resto_counter > self.max_soft_resto_iters {
338 self.in_soft_resto_phase = false;
339 self.soft_resto_counter = 0;
340 return self.fail_to_restoration(data);
341 }
342 // Per-outer-iteration acceptor hook (no-op for the filter
343 // acceptor; the penalty acceptor caches its reference here).
344 self.acceptor.init_this_line_search(data, cq, delta);
345 return match self.try_soft_resto_step(data, cq, delta) {
346 Some(satisfies_original) => {
347 if satisfies_original {
348 self.in_soft_resto_phase = false;
349 self.soft_resto_counter = 0;
350 data.borrow_mut().info_alpha_primal_char = 'S';
351 } else {
352 data.borrow_mut().info_alpha_primal_char = 's';
353 }
354 Outcome::Accepted
355 }
356 None => {
357 self.in_soft_resto_phase = false;
358 self.soft_resto_counter = 0;
359 self.fail_to_restoration(data)
360 }
361 };
362 }
363
364 // ---- Regular filter line search (watchdog + alpha loop).
365 let outcome =
366 self.run_filter_line_search(data, cq, delta, alpha_init, alpha_dual, nlp, search_dir);
367 if outcome == Outcome::Accepted {
368 return Outcome::Accepted;
369 }
370 // Time budget crossed (pounce#242): the caller is stopping the
371 // solve, so skip the soft-restoration attempt and hand the
372 // terminal outcome straight back.
373 if outcome == Outcome::Deadline {
374 return Outcome::Deadline;
375 }
376
377 // ---- Regular line search failed. Before the (expensive) full
378 // restoration sub-NLP, try to *enter* the soft restoration
379 // phase with one damped primal-dual step
380 // (`IpBacktrackingLineSearch.cpp:528-556`). `prepare_resto_phase_start`
381 // augments the outer filter with the entry envelope — mirrors
382 // upstream's `acceptor_->PrepareRestoPhaseStart()` at line 537.
383 let reference_theta = cq.borrow().curr_constraint_violation();
384 let reference_barr = cq.borrow().curr_barrier_obj();
385 self.acceptor
386 .prepare_resto_phase_start(reference_theta, reference_barr);
387 match self.try_soft_resto_step(data, cq, delta) {
388 Some(satisfies_original) => {
389 if satisfies_original {
390 data.borrow_mut().info_alpha_primal_char = 'S';
391 } else {
392 self.in_soft_resto_phase = true;
393 self.soft_resto_counter = 0;
394 data.borrow_mut().info_alpha_primal_char = 's';
395 }
396 Outcome::Accepted
397 }
398 // Soft resto could not help — fall through to full
399 // restoration with the original failure outcome. The
400 // caller's `invoke_restoration` re-runs
401 // `prepare_resto_phase_start`; the duplicate filter
402 // augmentation is idempotent (same envelope).
403 None => outcome,
404 }
405 }
406
407 /// Stamp the info fields for a hand-off to the full restoration
408 /// phase and return `Outcome::Failed`. Used when the soft
409 /// restoration phase exhausts its iteration budget or its step is
410 /// rejected mid-phase.
411 fn fail_to_restoration(&self, data: &IpoptDataHandle) -> Outcome {
412 let mut d = data.borrow_mut();
413 d.trial = None;
414 d.info_alpha_primal = 0.0;
415 d.info_alpha_dual = 0.0;
416 d.info_alpha_primal_char = 'R';
417 d.info_ls_count = 0;
418 Outcome::Failed
419 }
420
421 /// Attempt a single damped primal-dual step for the soft
422 /// restoration phase — port of
423 /// `BacktrackingLineSearch::TrySoftRestoStep`
424 /// (`IpBacktrackingLineSearch.cpp:1112-1217`). The step along
425 /// `delta` is damped only by the fraction-to-the-boundary rule,
426 /// with an identical step length for primal and dual variables.
427 ///
428 /// Returns:
429 /// - `Some(true)` — trial accepted *and* it satisfies the
430 /// original filter criterion ⇒ caller leaves soft resto ('S').
431 /// - `Some(false)` — trial accepted only on the primal-dual error
432 /// reduction test ⇒ caller stays in soft resto ('s').
433 /// - `None` — trial rejected (or soft resto disabled / a
434 /// non-finite evaluation) ⇒ caller falls through to the full
435 /// restoration phase.
436 ///
437 /// On a `Some(_)` return the accepted trial is left in `data.trial`
438 /// and the numeric `info_*` fields are stamped; the caller stamps
439 /// `info_alpha_primal_char`.
440 fn try_soft_resto_step(
441 &mut self,
442 data: &IpoptDataHandle,
443 cq: &IpoptCqHandle,
444 delta: &IteratesVector,
445 ) -> Option<bool> {
446 // Soft restoration is disabled when the reduction factor is
447 // zero (`IpBacktrackingLineSearch.cpp:1124`).
448 if self.soft_resto_pderror_reduction_factor == 0.0 {
449 return None;
450 }
451 let curr = data.borrow().curr.clone()?;
452 let tau = data.borrow().curr_tau;
453
454 // Identical step length for primal and dual variables, damped
455 // only by the fraction-to-the-boundary rule
456 // (`IpBacktrackingLineSearch.cpp:1135-1140`).
457 let alpha = {
458 let cq_ref = cq.borrow();
459 cq_ref
460 .aff_step_alpha_primal_max(delta, tau)
461 .min(cq_ref.aff_step_alpha_dual_max(delta, tau))
462 };
463
464 // Soft-resto uses the same scalar α for primal, equality
465 // multipliers, and bound multipliers (per upstream).
466 let trial_iv = scaled_step(&curr, delta, alpha, alpha, alpha);
467 data.borrow_mut().set_trial(trial_iv);
468
469 let theta_trial = cq.borrow().trial_constraint_violation();
470 let phi_trial = cq.borrow().trial_barrier_obj();
471 if !theta_trial.is_finite() || !phi_trial.is_finite() {
472 // Upstream retries up to three times on `Eval_Error`; the
473 // step length is fixed, so a non-finite eval here is
474 // deterministic — treat it as a rejection.
475 return None;
476 }
477
478 let theta = cq.borrow().curr_constraint_violation();
479 let phi = cq.borrow().curr_barrier_obj();
480 let d_phi = self.compute_d_phi(cq, delta);
481
482 // First test: is the trial acceptable to the *original*
483 // backtracking globalization? Upstream
484 // `acceptor_->CheckAcceptabilityOfTrialPoint(0.)`.
485 if self
486 .acceptor
487 .check_trial_point(0.0, theta, phi, d_phi, theta_trial, phi_trial)
488 == AcceptDecision::Accept
489 {
490 let mut d = data.borrow_mut();
491 d.info_alpha_primal = alpha;
492 d.info_alpha_dual = alpha;
493 d.info_ls_count = 1;
494 return Some(true);
495 }
496
497 // Second test: sufficient reduction in the primal-dual KKT
498 // system error (`IpBacktrackingLineSearch.cpp:1184-1211`).
499 let mu = data.borrow().curr_mu;
500 let curr_pderror = cq.borrow().curr_primal_dual_system_error(mu);
501 let trial_pderror = cq.borrow().trial_primal_dual_system_error(mu);
502 if !trial_pderror.is_finite() {
503 return None;
504 }
505 if trial_pderror <= self.soft_resto_pderror_reduction_factor * curr_pderror {
506 let mut d = data.borrow_mut();
507 d.info_alpha_primal = alpha;
508 d.info_alpha_dual = alpha;
509 d.info_ls_count = 1;
510 return Some(false);
511 }
512 None
513 }
514
515 /// Drive the watchdog state machine + alpha-reduction loop.
516 /// Port of `IpBacktrackingLineSearch::FindAcceptableTrialPoint`
517 /// (`IpBacktrackingLineSearch.cpp:252-677`) restricted to the
518 /// regular (non-soft-resto) filter-acceptor, exact-Hessian path.
519 /// The soft restoration phase is layered on top by
520 /// [`Self::find_acceptable_trial_point`].
521 ///
522 /// Outcomes:
523 /// - `Accepted`: a trial point is in `data.trial`, info fields are
524 /// stamped. The watchdog state has been advanced (success → "W",
525 /// `accept-anyway` → 'w').
526 /// - `TinyStep`: α dropped below the dynamic alpha-min before any
527 /// trial was accepted. Caller hands off to restoration.
528 /// - `Failed`: alpha-loop exhausted AND watchdog could not rescue.
529 /// Caller hands off to restoration.
530 #[allow(clippy::too_many_arguments)]
531 fn run_filter_line_search(
532 &mut self,
533 data: &IpoptDataHandle,
534 cq: &IpoptCqHandle,
535 delta: &IteratesVector,
536 alpha_init: Number,
537 alpha_dual: Number,
538 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
539 search_dir: Option<&mut PdSearchDirCalc>,
540 ) -> Outcome {
541 // ---- Watchdog: detect mu change → reset state.
542 // Mirrors `IpBacktrackingLineSearch.cpp:259-270`.
543 let curr_mu = data.borrow().curr_mu;
544 if self.last_mu < 0.0 || self.last_mu != curr_mu {
545 self.in_watchdog = false;
546 self.watchdog_iterate = None;
547 self.watchdog_delta = None;
548 self.watchdog_shortened_iter = 0;
549 self.last_mu = curr_mu;
550 }
551
552 // ---- Watchdog: maybe wake up.
553 // Mirrors `IpBacktrackingLineSearch.cpp:376-380`.
554 if !self.in_watchdog
555 && self.watchdog_shortened_iter_trigger > 0
556 && self.watchdog_shortened_iter >= self.watchdog_shortened_iter_trigger
557 {
558 self.start_watchdog(data, cq, delta);
559 }
560
561 // Per-outer-iteration acceptor hook.
562 self.acceptor.init_this_line_search(data, cq, delta);
563
564 // Decide reference (theta, phi, d_phi). Mirrors upstream's
565 // `FilterLSAcceptor::InitThisLineSearch(in_watchdog)` choice
566 // between `curr_*` and the saved `watchdog_*` snapshot.
567 let (theta, phi, d_phi) = if self.in_watchdog {
568 (self.watchdog_theta, self.watchdog_phi, self.watchdog_d_phi)
569 } else {
570 let theta = cq.borrow().curr_constraint_violation();
571 let phi = cq.borrow().curr_barrier_obj();
572 let d_phi = self.compute_d_phi(cq, delta);
573 (theta, phi, d_phi)
574 };
575
576 // Run the alpha-loop on the caller's `delta`.
577 let result = self.run_alpha_loop(
578 data, cq, delta, alpha_init, alpha_dual, nlp, search_dir, theta, phi, d_phi,
579 /*skip_first*/ false,
580 );
581
582 match result {
583 AlphaResult::Accepted { n_steps } => {
584 // Update the shortened-iter counter
585 // (`IpBacktrackingLineSearch.cpp:644-655`).
586 if n_steps == 0 {
587 self.watchdog_shortened_iter = 0;
588 } else {
589 self.watchdog_shortened_iter += 1;
590 }
591 if self.in_watchdog {
592 // Watchdog success — clear state, info char already
593 // stamped by the alpha loop's
594 // `update_for_next_iteration` call. Upstream also
595 // appends "W" to the info string here; pounce
596 // doesn't track an info string yet.
597 self.in_watchdog = false;
598 self.watchdog_iterate = None;
599 self.watchdog_delta = None;
600 self.watchdog_shortened_iter = 0;
601 }
602 Outcome::Accepted
603 }
604 AlphaResult::TinyStep {
605 n_steps,
606 last_alpha,
607 } => {
608 let mut d = data.borrow_mut();
609 d.trial = None;
610 d.info_alpha_primal = last_alpha;
611 d.info_alpha_dual = 0.0;
612 d.info_alpha_primal_char = 'R';
613 d.info_ls_count = n_steps + 1;
614 Outcome::TinyStep
615 }
616 AlphaResult::Failed {
617 n_steps,
618 last_alpha,
619 evaluation_error,
620 } => {
621 if self.in_watchdog {
622 self.handle_watchdog_failure(
623 data,
624 cq,
625 alpha_dual,
626 nlp,
627 n_steps,
628 last_alpha,
629 evaluation_error,
630 )
631 } else {
632 // Genuine failure → restoration.
633 let mut d = data.borrow_mut();
634 d.trial = None;
635 d.info_alpha_primal = last_alpha;
636 d.info_alpha_dual = 0.0;
637 d.info_alpha_primal_char = 'R';
638 d.info_ls_count = n_steps + 1;
639 Outcome::Failed
640 }
641 }
642 // Time budget crossed mid-loop (pounce#242) — terminal, and it
643 // pre-empts the watchdog: there is no point reverting to a
644 // snapshot when the caller is about to stop the solve.
645 AlphaResult::Deadline => Outcome::Deadline,
646 }
647 }
648
649 /// Snapshot the current `(curr, delta, theta, phi, d_phi)` and
650 /// activate the watchdog. Mirrors upstream
651 /// `IpBacktrackingLineSearch::StartWatchDog`
652 /// (`IpBacktrackingLineSearch.cpp:855-869`) plus
653 /// `IpFilterLSAcceptor::StartWatchDog`
654 /// (`IpFilterLSAcceptor.cpp:506-513`) — pounce stores the
655 /// frozen reference values directly on the driver because the
656 /// acceptor is stateless w.r.t. reference values (the driver
657 /// passes them per call).
658 fn start_watchdog(
659 &mut self,
660 data: &IpoptDataHandle,
661 cq: &IpoptCqHandle,
662 delta: &IteratesVector,
663 ) {
664 let curr = data.borrow().curr.clone();
665 let Some(curr) = curr else {
666 return;
667 };
668 self.in_watchdog = true;
669 self.watchdog_iterate = Some(curr);
670 self.watchdog_delta = Some(delta.clone());
671 self.watchdog_trial_iter = 0;
672 self.watchdog_theta = cq.borrow().curr_constraint_violation();
673 self.watchdog_phi = cq.borrow().curr_barrier_obj();
674 self.watchdog_d_phi = self.compute_d_phi(cq, delta);
675 }
676
677 /// Handle alpha-loop failure while in watchdog mode. Bumps
678 /// `watchdog_trial_iter`; if the cap is exceeded, reverts to the
679 /// snapshot (StopWatchDog) and re-runs the alpha-loop on the
680 /// saved `delta` with `skip_first=true`. Otherwise accepts the
681 /// current trial as 'w' and returns. Mirrors
682 /// `IpBacktrackingLineSearch.cpp:480-503` together with
683 /// `IpBacktrackingLineSearch.cpp:871-908`'s `StopWatchDog`.
684 fn handle_watchdog_failure(
685 &mut self,
686 data: &IpoptDataHandle,
687 cq: &IpoptCqHandle,
688 alpha_dual: Number,
689 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
690 n_steps: i32,
691 last_alpha: Number,
692 evaluation_error: bool,
693 ) -> Outcome {
694 self.watchdog_trial_iter += 1;
695 // Mirror upstream `IpBacktrackingLineSearch.cpp:493`:
696 // `if (evaluation_error || watchdog_trial_iter > max)` →
697 // StopWatchDog. A non-finite trial must NOT be promoted via
698 // the 'w' accept-anyway path; doing so propagates NaN/Inf
699 // into the next outer iter and the iterate is unrecoverable
700 // (observed on PFIT3, PFIT4).
701 if evaluation_error || self.watchdog_trial_iter > self.watchdog_trial_iter_max {
702 // StopWatchDog: revert curr to the snapshot, re-run on
703 // saved delta with `skip_first=true` (alpha starts at
704 // `alpha_init * alpha_red_factor`).
705 let snapshot_iter = self.watchdog_iterate.take();
706 let snapshot_delta = self.watchdog_delta.take();
707 self.in_watchdog = false;
708 self.watchdog_shortened_iter = 0;
709 let (Some(snap), Some(snap_delta)) = (snapshot_iter, snapshot_delta) else {
710 // Defensive — this should not happen if start_watchdog
711 // ran successfully. Fall through to genuine failure.
712 let mut d = data.borrow_mut();
713 d.trial = None;
714 d.info_alpha_primal = last_alpha;
715 d.info_alpha_dual = 0.0;
716 d.info_alpha_primal_char = 'R';
717 d.info_ls_count = n_steps + 1;
718 return Outcome::Failed;
719 };
720 {
721 let mut d = data.borrow_mut();
722 d.set_curr(snap);
723 }
724 let theta = cq.borrow().curr_constraint_violation();
725 let phi = cq.borrow().curr_barrier_obj();
726 let d_phi = self.compute_d_phi(cq, &snap_delta);
727 // Recompute the fraction-to-the-boundary caps from the
728 // *reverted* snapshot direction at the *reverted* iterate
729 // (`curr` was just set to `snap`). This mirrors upstream
730 // `IpBacktrackingLineSearch::FindAcceptableTrialPoint`, which
731 // recomputes `alpha_primal_max` / `alpha_dual_max` from
732 // `actual_delta_` after `StopWatchDog` has reverted it to the
733 // snapshot — the whole FindAcceptableTrialPoint body re-runs
734 // on the recovered direction, caps included.
735 //
736 // The failed direction's caps (the `alpha_init` / `alpha_dual`
737 // this method was handed, sized for the pre-revert iterate and
738 // the now-abandoned search direction) are NOT reused: applying
739 // them to `snap_delta` is wrong in both directions. If the
740 // failed cap is looser than the snapshot's FTB limit, the first
741 // retry trial overshoots the boundary — a negative slack /
742 // bound-multiplier, i.e. a non-finite barrier objective — and
743 // the loop wastes trials backtracking out of infeasibility; if
744 // tighter, it needlessly shortens a feasible step. Clamp by the
745 // full step `1.0` (the default `alpha_max`), matching the main
746 // path's `alpha_init.min(alpha_primal_max)` at
747 // `ipopt_alg.rs:1045`.
748 let tau = data.borrow().curr_tau;
749 let (alpha_primal_retry, alpha_dual_retry) = {
750 let cq_ref = cq.borrow();
751 (
752 1.0_f64.min(cq_ref.aff_step_alpha_primal_max(&snap_delta, tau)),
753 1.0_f64.min(cq_ref.aff_step_alpha_dual_max(&snap_delta, tau)),
754 )
755 };
756 // SOC is disabled on the StopWatchDog retry. The original
757 // `search_dir` was consumed by the first alpha-loop call
758 // and we want a plain backtracking pass over the saved
759 // delta; mirrors upstream's behavior of not running the
760 // soc_method on the recovered search (hence `search_dir =
761 // None` and `skip_first = true`, which starts the retry from
762 // `alpha_*_retry * alpha_red_factor`).
763 let result2 = self.run_alpha_loop(
764 data,
765 cq,
766 &snap_delta,
767 alpha_primal_retry,
768 alpha_dual_retry,
769 nlp,
770 None,
771 theta,
772 phi,
773 d_phi,
774 /*skip_first*/ true,
775 );
776 match result2 {
777 AlphaResult::Accepted { n_steps: ns2 } => {
778 if ns2 == 0 {
779 self.watchdog_shortened_iter = 0;
780 } else {
781 self.watchdog_shortened_iter += 1;
782 }
783 Outcome::Accepted
784 }
785 AlphaResult::TinyStep {
786 n_steps: ns2,
787 last_alpha: la2,
788 } => {
789 let mut d = data.borrow_mut();
790 d.trial = None;
791 d.info_alpha_primal = la2;
792 d.info_alpha_dual = 0.0;
793 d.info_alpha_primal_char = 'R';
794 d.info_ls_count = ns2 + 1;
795 Outcome::TinyStep
796 }
797 AlphaResult::Failed {
798 n_steps: ns2,
799 last_alpha: la2,
800 evaluation_error: _,
801 } => {
802 let mut d = data.borrow_mut();
803 d.trial = None;
804 d.info_alpha_primal = la2;
805 d.info_alpha_dual = 0.0;
806 d.info_alpha_primal_char = 'R';
807 d.info_ls_count = ns2 + 1;
808 Outcome::Failed
809 }
810 // Deadline crossed during the StopWatchDog retry sweep
811 // (pounce#242) — propagate the terminal outcome.
812 AlphaResult::Deadline => Outcome::Deadline,
813 }
814 } else {
815 // Accept the last attempted trial despite filter rejection
816 // — `accept-anyway` watchdog branch
817 // (`IpBacktrackingLineSearch.cpp:498-503`). The trial
818 // iterate from the final α attempt is already in
819 // `data.trial`. Crucially, we do NOT call
820 // `update_for_next_iteration`, so the filter is NOT
821 // augmented (matching upstream's char='w' branch at
822 // line 833-836 which skips `UpdateForNextIteration`).
823 let mut d = data.borrow_mut();
824 d.info_alpha_primal = last_alpha;
825 d.info_alpha_dual = alpha_dual;
826 d.info_alpha_primal_char = 'w';
827 d.info_ls_count = n_steps + 1;
828 Outcome::Accepted
829 }
830 }
831
832 /// Inner alpha-reduction loop. Tries
833 /// `alpha = alpha_init * alpha_red_factor^k` (or
834 /// `alpha_red_factor^(k+1)` when `skip_first=true`) and consults
835 /// the acceptor against the supplied reference `(theta, phi, d_phi)`.
836 /// On accept stamps the info fields and calls
837 /// `update_for_next_iteration`. On reject leaves the LAST trial in
838 /// `data.trial` so the watchdog `accept-anyway` path can promote
839 /// it.
840 #[allow(clippy::too_many_arguments)]
841 fn run_alpha_loop(
842 &mut self,
843 data: &IpoptDataHandle,
844 cq: &IpoptCqHandle,
845 delta: &IteratesVector,
846 alpha_init: Number,
847 alpha_dual: Number,
848 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
849 search_dir: Option<&mut PdSearchDirCalc>,
850 theta: Number,
851 phi: Number,
852 d_phi: Number,
853 skip_first: bool,
854 ) -> AlphaResult {
855 let curr = match data.borrow().curr.clone() {
856 Some(c) => c,
857 None => {
858 return AlphaResult::Failed {
859 n_steps: 0,
860 last_alpha: 0.0,
861 evaluation_error: false,
862 };
863 }
864 };
865
866 let mut evaluation_error = false;
867
868 let mut soc_search_dir = search_dir;
869 let (mut c_soc_buf, mut dms_soc_buf) =
870 if soc_search_dir.is_some() && nlp.is_some() && self.max_soc > 0 && !skip_first {
871 let cq_ref = cq.borrow();
872 let curr_c = cq_ref.curr_c();
873 let curr_dms = cq_ref.curr_d_minus_s();
874 let mut c_soc = curr_c.make_new();
875 c_soc.copy(&*curr_c);
876 let mut dms_soc = curr_dms.make_new();
877 dms_soc.copy(&*curr_dms);
878 (Some(c_soc), Some(dms_soc))
879 } else {
880 (None, None)
881 };
882
883 let mut alpha = if skip_first {
884 alpha_init * self.alpha_red_factor
885 } else {
886 alpha_init
887 };
888 let mut last_alpha = alpha;
889 let mut n_steps: i32 = 0;
890 // Smallest step allowed before the loop bails. Upstream
891 // `DoBacktrackingLineSearch` sets `alpha_min = alpha_primal_max`
892 // (the FTB max step) while in the watchdog window, *bypassing*
893 // the acceptor's `CalculateAlphaMin`
894 // (`IpBacktrackingLineSearch.cpp:700-704`). Together with the
895 // `|| n_steps == 0` loop guard (cpp:740) this guarantees the
896 // single full-step watchdog trial always runs, is rejected, and
897 // is then routed through the watchdog handler (accept-anyway 'w'
898 // or `StopWatchDog` revert). If pounce instead applied the
899 // acceptor floor here, a tiny FTB step under watchdog (e.g.
900 // scon1dls iter 50, alpha ~6e-13 << acceptor min) would trip the
901 // `alpha < alpha_min_eff` early-out below with zero trials and
902 // return `TinyStep`, which `run_filter_line_search` hands back
903 // directly — bypassing `handle_watchdog_failure`. The watchdog
904 // would never revert, `curr` would stay at the diverged iterate,
905 // and the solve would die with `ErrorInStepComputation` while
906 // upstream IPOPT converges.
907 let alpha_min_eff = if self.in_watchdog {
908 alpha_init
909 } else {
910 let acceptor_alpha_min = self.acceptor.calc_alpha_min(d_phi, theta);
911 self.alpha_min.max(acceptor_alpha_min)
912 };
913
914 for trial in 0..self.max_trials {
915 // Fine-grained time-budget gate (pounce#242): each trial
916 // evaluates the constraints / barrier objective, which on a
917 // large problem is not cheap, so honor the deadline at
918 // per-trial granularity rather than letting a full backtracking
919 // sweep run past it. Bail before staging another trial; no
920 // trial is promoted, so `data.curr` stays the best iterate.
921 if data
922 .borrow()
923 .deadline
924 .as_ref()
925 .is_some_and(|dl| dl.exceeded().is_some())
926 {
927 return AlphaResult::Deadline;
928 }
929 if alpha < alpha_min_eff {
930 return AlphaResult::TinyStep {
931 n_steps,
932 last_alpha,
933 };
934 }
935 last_alpha = alpha;
936 n_steps = trial;
937
938 let alpha_y = self.alpha_for_y.alpha_y(alpha, alpha_dual);
939 let trial_iv = scaled_step(&curr, delta, alpha, alpha_y, alpha_dual);
940 data.borrow_mut().set_trial(trial_iv);
941
942 let theta_trial = cq.borrow().trial_constraint_violation();
943 let phi_trial = cq.borrow().trial_barrier_obj();
944 if !theta_trial.is_finite() || !phi_trial.is_finite() {
945 // Mirror upstream `IpBacktrackingLineSearch.cpp:776-784`:
946 // a non-finite eval is treated as `Eval_Error`, sets the
947 // `evaluation_error` flag, and the alpha-loop continues
948 // to backtrack. Under watchdog, upstream breaks out
949 // immediately (line 791-794) so the watchdog handler
950 // can force StopWatchDog via line 493.
951 evaluation_error = true;
952 if self.in_watchdog {
953 return AlphaResult::Failed {
954 n_steps: trial,
955 last_alpha: alpha,
956 evaluation_error: true,
957 };
958 }
959 alpha *= self.alpha_red_factor;
960 continue;
961 }
962
963 let decision =
964 self.acceptor
965 .check_trial_point(alpha, theta, phi, d_phi, theta_trial, phi_trial);
966 if decision == AcceptDecision::Accept {
967 let mode = self
968 .acceptor
969 .update_for_next_iteration(alpha, theta, phi, d_phi, phi_trial);
970 if std::env::var_os("POUNCE_DBG_LS").is_some() {
971 let d = data.borrow();
972 tracing::debug!(target: "pounce::linesearch",
973 "[PN_LS] iter={} mu={:.3e} alpha={:.3e} alpha_d={:.3e} mode={} theta={:.6e} theta_trial={:.6e} phi={:.6e} phi_trial={:.6e} n_steps={}",
974 d.iter_count, d.curr_mu, alpha, alpha_dual, mode, theta, theta_trial, phi, phi_trial, trial
975 );
976 }
977 let mut d = data.borrow_mut();
978 d.info_alpha_primal = alpha;
979 d.info_alpha_dual = alpha_dual;
980 d.info_ls_count = trial + 1;
981 d.info_alpha_primal_char = mode;
982 return AlphaResult::Accepted { n_steps: trial };
983 }
984
985 // Watchdog: under upstream `IpBacktrackingLineSearch.cpp:791-794`,
986 // a failed trial inside the watchdog window breaks out of the
987 // alpha-loop immediately — alpha is NOT reduced. The trial just
988 // attempted (at the full `alpha_init`) is left in `data.trial`
989 // so `handle_watchdog_failure` can promote it via the 'w'
990 // accept-anyway branch. Without this break, pounce kept
991 // reducing alpha under watchdog and accepted the same tiny
992 // step that triggered watchdog activation in the first place,
993 // leaving the iterate stalled (observed on HATFLDFLNE: iter 11
994 // accepted α=1.22e-4 'h' instead of α=1.00 'w').
995 if self.in_watchdog {
996 return AlphaResult::Failed {
997 n_steps: trial,
998 last_alpha: alpha,
999 evaluation_error,
1000 };
1001 }
1002
1003 // SOC: only on the first non-skipped trial when constraint
1004 // violation grew. Disabled when `skip_first=true` (no SOC
1005 // buffers were allocated). Also disabled under watchdog (the
1006 // `in_watchdog` break above pre-empts SOC, matching upstream
1007 // which gates SOC after the in_watchdog break).
1008 if trial == 0
1009 && !skip_first
1010 && self.max_soc > 0
1011 && theta <= theta_trial
1012 && c_soc_buf.is_some()
1013 && dms_soc_buf.is_some()
1014 {
1015 let alpha_test = alpha;
1016 let mut count_soc: i32 = 0;
1017 let mut theta_soc_old: Number = 0.0;
1018 let mut theta_trial_local = theta_trial;
1019 let mut alpha_primal_soc = alpha;
1020 let mut soc_accepted = false;
1021 while count_soc < self.max_soc
1022 && !soc_accepted
1023 && (count_soc == 0 || theta_trial_local <= self.kappa_soc * theta_soc_old)
1024 {
1025 theta_soc_old = theta_trial_local;
1026 {
1027 let cq_ref = cq.borrow();
1028 let trial_c = cq_ref.trial_c();
1029 let trial_dms = cq_ref.trial_d_minus_s();
1030 if let Some(c_soc) = c_soc_buf.as_mut() {
1031 c_soc.scal(alpha_primal_soc);
1032 c_soc.axpy(1.0, &*trial_c);
1033 }
1034 if let Some(dms_soc) = dms_soc_buf.as_mut() {
1035 dms_soc.scal(alpha_primal_soc);
1036 dms_soc.axpy(1.0, &*trial_dms);
1037 }
1038 }
1039 let delta_soc_opt = {
1040 let sd = soc_search_dir
1041 .as_deref_mut()
1042 .expect("SOC: search_dir is gated above");
1043 let nlp_ref = nlp.expect("SOC: nlp is gated above");
1044 let c_soc = c_soc_buf.as_deref().expect("SOC: c_soc_buf is gated above");
1045 let dms_soc = dms_soc_buf
1046 .as_deref()
1047 .expect("SOC: dms_soc_buf is gated above");
1048 sd.compute_soc_step(
1049 data,
1050 cq,
1051 nlp_ref,
1052 c_soc,
1053 dms_soc,
1054 alpha_primal_soc,
1055 self.soc_method,
1056 )
1057 };
1058 let Some(delta_soc) = delta_soc_opt else {
1059 break;
1060 };
1061 let tau = data.borrow().curr_tau;
1062 alpha_primal_soc = cq.borrow().aff_step_alpha_primal_max(&delta_soc, tau);
1063 // Upstream `IpFilterLSAcceptor.cpp` sets `actual_delta =
1064 // delta_soc` on an accepted SOC step: the *entire* step,
1065 // primal and dual, is replaced. The dual update therefore
1066 // uses the SOC step's own multiplier components — not the
1067 // original `delta` — and the dual fraction-to-boundary is
1068 // recomputed from `delta_soc`
1069 // (`IpBacktrackingLineSearch.cpp:639`). Applying `delta`'s
1070 // duals here left the accepted iterate with a primal from
1071 // `delta_soc` but duals from `delta`, diverging `inf_du`
1072 // from Ipopt on any `H`-flagged iteration (e.g. CRESC4).
1073 let alpha_dual_soc = cq.borrow().aff_step_alpha_dual_max(&delta_soc, tau);
1074 let mut trial_iv = curr.deep_copy();
1075 trial_iv.x.axpy(alpha_primal_soc, &*delta_soc.x);
1076 trial_iv.s.axpy(alpha_primal_soc, &*delta_soc.s);
1077 trial_iv.y_c.axpy(alpha_primal_soc, &*delta_soc.y_c);
1078 trial_iv.y_d.axpy(alpha_primal_soc, &*delta_soc.y_d);
1079 trial_iv.z_l.axpy(alpha_dual_soc, &*delta_soc.z_l);
1080 trial_iv.z_u.axpy(alpha_dual_soc, &*delta_soc.z_u);
1081 trial_iv.v_l.axpy(alpha_dual_soc, &*delta_soc.v_l);
1082 trial_iv.v_u.axpy(alpha_dual_soc, &*delta_soc.v_u);
1083 let trial_iv = trial_iv.freeze();
1084 data.borrow_mut().set_trial(trial_iv);
1085 let theta_soc = cq.borrow().trial_constraint_violation();
1086 let phi_soc = cq.borrow().trial_barrier_obj();
1087 if !theta_soc.is_finite() || !phi_soc.is_finite() {
1088 break;
1089 }
1090 let dec = self
1091 .acceptor
1092 .check_trial_point(alpha_test, theta, phi, d_phi, theta_soc, phi_soc);
1093 if dec == AcceptDecision::Accept {
1094 let mode = self
1095 .acceptor
1096 .update_for_next_iteration(alpha_test, theta, phi, d_phi, phi_soc);
1097 let mut d = data.borrow_mut();
1098 d.info_alpha_primal = alpha_primal_soc;
1099 d.info_alpha_dual = alpha_dual_soc;
1100 d.info_ls_count = trial + 1;
1101 d.info_alpha_primal_char = mode.to_ascii_uppercase();
1102 return AlphaResult::Accepted { n_steps: trial };
1103 }
1104 count_soc += 1;
1105 theta_trial_local = theta_soc;
1106 soc_accepted = false;
1107 }
1108 }
1109
1110 alpha *= self.alpha_red_factor;
1111 }
1112
1113 AlphaResult::Failed {
1114 n_steps,
1115 last_alpha,
1116 evaluation_error,
1117 }
1118 }
1119
1120 /// Directional derivative of the barrier objective along the step
1121 /// `delta`: `d_phi = ∇_x φ · dx + ∇_s φ · ds`.
1122 fn compute_d_phi(&self, cq: &IpoptCqHandle, delta: &IteratesVector) -> Number {
1123 let cq_ref = cq.borrow();
1124 let g_x = cq_ref.curr_grad_barrier_obj_x();
1125 let g_s = cq_ref.curr_grad_barrier_obj_s();
1126 g_x.dot(&*delta.x) + g_s.dot(&*delta.s)
1127 }
1128}
1129
1130/// `out = curr + alpha * delta` for all eight components, returned as a
1131/// fresh `IteratesVector` with `Rc<dyn Vector>` slots. Mirrors
1132/// `IpoptData::SetTrialBoundMultipliersFromStep` + the primal step
1133/// path in upstream — both share the same scalar α here because
1134/// fraction-to-the-boundary truncation has already been folded into
1135/// `alpha_init` upstream.
1136fn scaled_step(
1137 curr: &IteratesVector,
1138 delta: &IteratesVector,
1139 alpha_primal: Number,
1140 alpha_y: Number,
1141 alpha_dual: Number,
1142) -> IteratesVector {
1143 let mut out = curr.make_new_zeroed();
1144 out.add_one_vector(1.0, curr, 0.0); // out = curr
1145 out.x.axpy(alpha_primal, &*delta.x);
1146 out.s.axpy(alpha_primal, &*delta.s);
1147 out.y_c.axpy(alpha_y, &*delta.y_c);
1148 out.y_d.axpy(alpha_y, &*delta.y_d);
1149 out.z_l.axpy(alpha_dual, &*delta.z_l);
1150 out.z_u.axpy(alpha_dual, &*delta.z_u);
1151 out.v_l.axpy(alpha_dual, &*delta.v_l);
1152 out.v_u.axpy(alpha_dual, &*delta.v_u);
1153 out.freeze()
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use crate::ipopt_cq::IpoptCalculatedQuantities;
1160 use crate::ipopt_data::IpoptData;
1161 use crate::ipopt_nlp::Nlp;
1162 use crate::iterates_vector::IteratesVector;
1163 use crate::line_search::filter_acceptor::FilterLsAcceptor;
1164 use pounce_common::types::Index;
1165 use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
1166 use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
1167 use pounce_linalg::{Matrix, SymMatrix, Vector};
1168 use std::rc::Rc;
1169
1170 fn dense(n: i32, vals: &[Number]) -> Rc<dyn Vector> {
1171 let mut v = DenseVectorSpace::new(n).make_new_dense();
1172 v.set(0.0);
1173 if !vals.is_empty() {
1174 v.values_mut().copy_from_slice(vals);
1175 }
1176 Rc::new(v)
1177 }
1178
1179 fn dvec(vals: &[Number]) -> DenseVector {
1180 let mut v = DenseVectorSpace::new(vals.len() as Index).make_new_dense();
1181 v.set(0.0);
1182 if !vals.is_empty() {
1183 v.values_mut().copy_from_slice(vals);
1184 }
1185 v
1186 }
1187
1188 /// Minimal NLP for the F4 watchdog test: one variable `x[0] >= 0`,
1189 /// no constraints. `f(x) = x[0]^2`. The only finite bound is the
1190 /// lower bound on `x[0]`, so the primal fraction-to-the-boundary cap
1191 /// is governed entirely by the `x[0]` slack.
1192 struct F4MockNlp {
1193 x_l: DenseVector,
1194 x_u: DenseVector,
1195 d_l: DenseVector,
1196 d_u: DenseVector,
1197 px_l: Rc<dyn Matrix>,
1198 px_u: Rc<dyn Matrix>,
1199 pd_l: Rc<dyn Matrix>,
1200 pd_u: Rc<dyn Matrix>,
1201 }
1202
1203 impl F4MockNlp {
1204 fn new() -> Self {
1205 Self {
1206 x_l: dvec(&[0.0]),
1207 x_u: dvec(&[]),
1208 d_l: dvec(&[]),
1209 d_u: dvec(&[]),
1210 // P_L lifts the single lower-bounded var (col 0) into x[0].
1211 px_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1212 1,
1213 1,
1214 &[0],
1215 0,
1216 ))),
1217 px_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1218 1,
1219 0,
1220 &[],
1221 0,
1222 ))),
1223 pd_l: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1224 0,
1225 0,
1226 &[],
1227 0,
1228 ))),
1229 pd_u: Rc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
1230 0,
1231 0,
1232 &[],
1233 0,
1234 ))),
1235 }
1236 }
1237 }
1238
1239 impl Nlp for F4MockNlp {
1240 fn n(&self) -> Index {
1241 1
1242 }
1243 fn m_eq(&self) -> Index {
1244 0
1245 }
1246 fn m_ineq(&self) -> Index {
1247 0
1248 }
1249 fn eval_f(&mut self, x: &dyn Vector) -> Number {
1250 let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1251 xx.values()[0] * xx.values()[0]
1252 }
1253 fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1254 let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1255 let gg = g.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1256 gg.values_mut()[0] = 2.0 * xx.values()[0];
1257 }
1258 fn eval_c(&mut self, _x: &dyn Vector, _c: &mut dyn Vector) {}
1259 fn eval_d(&mut self, _x: &dyn Vector, _d: &mut dyn Vector) {}
1260 fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1261 unimplemented!("no equality constraints in the F4 watchdog fixture")
1262 }
1263 fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1264 unimplemented!("no inequality constraints in the F4 watchdog fixture")
1265 }
1266 fn eval_h(
1267 &mut self,
1268 _x: &dyn Vector,
1269 _obj_factor: Number,
1270 _y_c: &dyn Vector,
1271 _y_d: &dyn Vector,
1272 ) -> Rc<dyn SymMatrix> {
1273 unimplemented!("Hessian not exercised by the line search")
1274 }
1275 }
1276
1277 impl IpoptNlp for F4MockNlp {
1278 fn x_l(&self) -> &dyn Vector {
1279 &self.x_l
1280 }
1281 fn x_u(&self) -> &dyn Vector {
1282 &self.x_u
1283 }
1284 fn d_l(&self) -> &dyn Vector {
1285 &self.d_l
1286 }
1287 fn d_u(&self) -> &dyn Vector {
1288 &self.d_u
1289 }
1290 fn px_l(&self) -> Rc<dyn Matrix> {
1291 self.px_l.clone()
1292 }
1293 fn px_u(&self) -> Rc<dyn Matrix> {
1294 self.px_u.clone()
1295 }
1296 fn pd_l(&self) -> Rc<dyn Matrix> {
1297 self.pd_l.clone()
1298 }
1299 fn pd_u(&self) -> Rc<dyn Matrix> {
1300 self.pd_u.clone()
1301 }
1302 }
1303
1304 /// Acceptor that accepts the first trial unconditionally and records
1305 /// the primal step it was offered — lets the test read back the
1306 /// alpha the StopWatchDog retry started from.
1307 struct RecordingAcceptor {
1308 first_alpha: Rc<RefCell<Option<Number>>>,
1309 }
1310
1311 impl BacktrackingLsAcceptor for RecordingAcceptor {
1312 fn reset(&mut self) {}
1313 fn check_trial_point(
1314 &mut self,
1315 alpha_primal: Number,
1316 _theta: Number,
1317 _phi: Number,
1318 _d_phi: Number,
1319 _theta_trial: Number,
1320 _phi_trial: Number,
1321 ) -> AcceptDecision {
1322 let mut slot = self.first_alpha.borrow_mut();
1323 if slot.is_none() {
1324 *slot = Some(alpha_primal);
1325 }
1326 AcceptDecision::Accept
1327 }
1328 }
1329
1330 fn empty() -> Rc<dyn Vector> {
1331 dense(0, &[])
1332 }
1333
1334 /// F4 (L7 reopen): on the StopWatchDog revert, the alpha-loop retry
1335 /// must restart from the fraction-to-the-boundary cap of the
1336 /// *snapshot* direction at the *reverted* iterate — NOT the failed
1337 /// direction's cap. Pre-fix `handle_watchdog_failure` reused
1338 /// `alpha_init` (the failed direction's cap); this test pins the
1339 /// retry's first trial alpha to the recomputed snapshot cap.
1340 #[test]
1341 fn stop_watchdog_retry_recomputes_ftb_cap_from_snapshot_direction() {
1342 let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(F4MockNlp::new()));
1343 let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1344
1345 // Snapshot iterate: x = 2 (so the x[0] slack is 2), z_L = 0.5.
1346 let snap = IteratesVector::new(
1347 dense(1, &[2.0]),
1348 empty(),
1349 empty(),
1350 empty(),
1351 dense(1, &[0.5]),
1352 empty(),
1353 empty(),
1354 empty(),
1355 );
1356 {
1357 let mut d = data.borrow_mut();
1358 d.curr_mu = 0.1;
1359 d.curr_tau = 1.0;
1360 d.set_curr(snap.clone());
1361 }
1362 let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1363 data.clone(),
1364 nlp,
1365 )));
1366
1367 // Snapshot search direction: Δx = -4. At x = 2 with τ = 1 the
1368 // fraction-to-the-boundary cap is τ·s/|Δx| = 1·2/4 = 0.5.
1369 let snap_delta = IteratesVector::new(
1370 dense(1, &[-4.0]),
1371 empty(),
1372 empty(),
1373 empty(),
1374 dense(1, &[0.0]),
1375 empty(),
1376 empty(),
1377 empty(),
1378 );
1379
1380 let recorded = Rc::new(RefCell::new(None));
1381 let mut bls = BacktrackingLineSearch::new(Box::new(RecordingAcceptor {
1382 first_alpha: recorded.clone(),
1383 }));
1384
1385 // Arm the watchdog at the snapshot and put it one trial over the
1386 // cap, so the next failure triggers StopWatchDog (revert + retry).
1387 bls.in_watchdog = true;
1388 bls.watchdog_iterate = Some(snap.clone());
1389 bls.watchdog_delta = Some(snap_delta);
1390 bls.watchdog_trial_iter = bls.watchdog_trial_iter_max;
1391
1392 let outcome = bls.handle_watchdog_failure(
1393 &data, &cq, /*alpha_dual*/ 1.0, None, /*n_steps*/ 0, /*last_alpha*/ 1.0,
1394 /*evaluation_error*/ false,
1395 );
1396 assert_eq!(outcome, Outcome::Accepted);
1397
1398 // skip_first halves the recomputed cap: 0.5 × alpha_red_factor
1399 // (0.5) = 0.25. The failed direction's cap would differ.
1400 let a = recorded
1401 .borrow()
1402 .expect("acceptor must have seen at least one trial");
1403 assert!(
1404 (a - 0.25).abs() < 1e-12,
1405 "retry first alpha = {a}, expected 0.25 (snapshot FTB cap 0.5 × red 0.5)"
1406 );
1407 }
1408
1409 /// pounce#242: an already-crossed shared [`Deadline`] on `data` makes
1410 /// the alpha loop bail on its very first trial with `Outcome::Deadline`
1411 /// — before staging or evaluating any trial point — so the main loop
1412 /// can stop the solve at per-trial granularity while `data.curr`
1413 /// (untouched) remains the best iterate.
1414 #[test]
1415 fn deadline_short_circuits_the_alpha_loop() {
1416 let nlp: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(F4MockNlp::new()));
1417 let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::new()));
1418 let curr = IteratesVector::new(
1419 dense(1, &[2.0]),
1420 empty(),
1421 empty(),
1422 empty(),
1423 dense(1, &[0.5]),
1424 empty(),
1425 empty(),
1426 empty(),
1427 );
1428 {
1429 let mut d = data.borrow_mut();
1430 d.curr_mu = 0.1;
1431 d.curr_tau = 1.0;
1432 d.set_curr(curr.clone());
1433 // Zero wall budget — already crossed by the time the loop runs.
1434 d.deadline = Some(pounce_common::timing::Deadline::new(0.0, 1e6));
1435 }
1436 let cq: IpoptCqHandle = Rc::new(RefCell::new(IpoptCalculatedQuantities::new(
1437 data.clone(),
1438 nlp.clone(),
1439 )));
1440 let delta = IteratesVector::new(
1441 dense(1, &[-1.0]),
1442 empty(),
1443 empty(),
1444 empty(),
1445 dense(1, &[0.0]),
1446 empty(),
1447 empty(),
1448 empty(),
1449 );
1450 let mut bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1451 let outcome = bls.find_acceptable_trial_point(
1452 &data,
1453 &cq,
1454 &delta,
1455 /*alpha_init*/ 1.0,
1456 /*alpha_dual*/ 1.0,
1457 Some(&nlp),
1458 None,
1459 );
1460 assert_eq!(outcome, Outcome::Deadline);
1461 // No trial was staged/promoted — curr is still the best iterate.
1462 assert!(data.borrow().trial.is_none());
1463 }
1464
1465 fn iv_from(x: &[Number], s: &[Number]) -> IteratesVector {
1466 IteratesVector::new(
1467 dense(x.len() as i32, x),
1468 dense(s.len() as i32, s),
1469 dense(0, &[]),
1470 dense(0, &[]),
1471 dense(0, &[]),
1472 dense(0, &[]),
1473 dense(0, &[]),
1474 dense(0, &[]),
1475 )
1476 }
1477
1478 #[test]
1479 fn driver_constructs_with_defaults() {
1480 let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1481 assert_eq!(bls.alpha_red_factor, 0.5);
1482 assert_eq!(bls.max_soc, 4);
1483 }
1484
1485 #[test]
1486 fn scaled_step_writes_curr_plus_alpha_delta() {
1487 // curr.x = (0,0), delta.x = (1,1) → at alpha=0.5, trial.x = (0.5, 0.5).
1488 let curr = iv_from(&[0.0, 0.0], &[0.0]);
1489 let delta = iv_from(&[1.0, 1.0], &[2.0]);
1490 let trial = scaled_step(&curr, &delta, 0.5, 0.5, 0.5);
1491 let xv = trial
1492 .x
1493 .as_any()
1494 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1495 .unwrap()
1496 .values()
1497 .to_vec();
1498 assert_eq!(xv, vec![0.5, 0.5]);
1499 let sv = trial
1500 .s
1501 .as_any()
1502 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1503 .unwrap()
1504 .values()
1505 .to_vec();
1506 assert_eq!(sv, vec![1.0]); // 0.0 + 0.5 * 2.0
1507 }
1508
1509 #[test]
1510 fn outcome_variants_are_distinct() {
1511 assert_ne!(Outcome::Accepted, Outcome::Failed);
1512 assert_ne!(Outcome::Accepted, Outcome::TinyStep);
1513 assert_ne!(Outcome::Failed, Outcome::TinyStep);
1514 }
1515
1516 #[test]
1517 fn watchdog_state_starts_inactive() {
1518 // Mirror upstream `IpBacktrackingLineSearch::InitializeImpl`
1519 // (`IpBacktrackingLineSearch.cpp:240-249`): the watchdog is
1520 // inactive at construction and `last_mu_` is initialised to
1521 // a sentinel `-1` so the first iteration's mu always
1522 // triggers the reset branch (which is harmless when the
1523 // watchdog was never armed).
1524 let bls = BacktrackingLineSearch::new(Box::new(FilterLsAcceptor::new()));
1525 assert!(!bls.in_watchdog());
1526 assert_eq!(bls.watchdog_shortened_iter(), 0);
1527 assert!(bls.last_mu < 0.0);
1528 assert_eq!(bls.watchdog_shortened_iter_trigger, 10);
1529 assert_eq!(bls.watchdog_trial_iter_max, 3);
1530 }
1531
1532 #[test]
1533 fn alpha_result_failed_carries_n_steps_and_last_alpha() {
1534 // Sanity check on the internal AlphaResult enum: the watchdog
1535 // wrapper relies on `Failed { n_steps, last_alpha }` to stamp
1536 // the info-* fields when handing off to restoration.
1537 let r = AlphaResult::Failed {
1538 n_steps: 7,
1539 last_alpha: 1e-6,
1540 evaluation_error: false,
1541 };
1542 match r {
1543 AlphaResult::Failed {
1544 n_steps,
1545 last_alpha,
1546 evaluation_error,
1547 } => {
1548 assert_eq!(n_steps, 7);
1549 assert!((last_alpha - 1e-6).abs() < 1e-20);
1550 assert!(!evaluation_error);
1551 }
1552 _ => unreachable!(),
1553 }
1554 }
1555}