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