pounce_algorithm/ipopt_alg.rs
1//! Main optimization loop — port of
2//! `Algorithm/IpIpoptAlg.{hpp,cpp}`.
3//!
4//! Phase 7 ships the loop scaffold matching `Optimize()` lines
5//! 292-563 in upstream. The body invokes:
6//!
7//! 1. `IterateInitializer::set_initial_iterates`
8//! 2. (loop) `OutputIteration` → `CheckConvergence` →
9//! `UpdateBarrierParameter` → `UpdateHessian` →
10//! `ComputeSearchDirection` → `ComputeAcceptableTrialPoint` →
11//! `AcceptTrialPoint`
12//! 3. `correct_bound_multiplier` (kappa_sigma) per `MAIN_LOOP.md`
13//! §"Bound multiplier reset" lines 1055-1134
14//! 4. exception → `SolverReturn` mapping per the table in
15//! `MAIN_LOOP.md`.
16//!
17//! The NLP handle and search-direction calculator are optional:
18//! when both are present, `iterate()` computes a real Newton step and
19//! drives the line search. Without them, `iterate()` runs the bookkeeping
20//! pieces (mu update, hessian update, conv check, kappa_sigma reset)
21//! and is exercised by structural unit tests. The full path lights up
22//! once `pounce-nlp::OrigIpoptNLP` lands.
23
24use crate::alg_builder::AlgorithmBundle;
25use crate::conv_check::r#trait::ConvergenceStatus;
26use crate::intermediate::{CtxGuard, IntermediateContext};
27use crate::ipopt_cq::IpoptCqHandle;
28use crate::ipopt_data::IpoptDataHandle;
29use crate::ipopt_nlp::IpoptNlp;
30use crate::iter_dump::IterDumper;
31use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
32use crate::line_search::backtracking::Outcome;
33use crate::restoration::{RestorationOutcome, RestorationPhase};
34use pounce_common::diagnostics::DiagnosticsState;
35use pounce_common::types::{Index, Number};
36use pounce_linalg::Vector;
37use pounce_nlp::alg_types::SolverReturn;
38use pounce_nlp::return_codes::AlgorithmMode;
39use pounce_nlp::tnlp::{IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, IterStats, TNLP};
40use std::cell::RefCell;
41use std::rc::Rc;
42
43pub struct IpoptAlgorithm {
44 pub data: IpoptDataHandle,
45 pub cq: IpoptCqHandle,
46 pub bundle: AlgorithmBundle,
47 /// Optional NLP handle. Required for any step that evaluates
48 /// problem functions or pulls bound expansion matrices (init,
49 /// search direction, line-search trial-point evaluation). Absent
50 /// in the structural unit tests of Phases 5-6.
51 pub nlp: Option<Rc<RefCell<dyn IpoptNlp>>>,
52 /// Optional TNLP handle — the user-facing problem. When present,
53 /// `iterate()` fires `TNLP::intermediate_callback` once per outer
54 /// iteration so callers can monitor progress or request early
55 /// termination (returning `false` from the callback surfaces as
56 /// `SolverReturn::UserRequestedStop`). Kept separate from `nlp`
57 /// because the algorithm-side NLP is the *compressed* `OrigIpoptNlp`
58 /// view (fixed-variable elimination, c/d split) while the callback
59 /// payload needs to expose the original-coordinate iterate.
60 pub tnlp: Option<Rc<RefCell<dyn TNLP>>>,
61 /// Search-direction calculator (`PdSearchDirCalc`). Lands once a
62 /// concrete `SymLinearSolver` backend (MUMPS / FERAL) is wired
63 /// through `AlgBuilder` in Phase 7's tail.
64 pub search_dir: Option<PdSearchDirCalc>,
65 /// Restoration-phase strategy. Invoked when the line search
66 /// returns [`Outcome::Failed`] (port of upstream
67 /// `IpBacktrackingLineSearch::ActivateLineSearch`'s resto
68 /// fallback). Optional: in its absence, line-search failure maps
69 /// directly to [`SolverReturn::RestorationFailure`] so the main
70 /// loop's exit-code semantics match upstream's "no resto built"
71 /// case.
72 pub restoration: Option<Box<dyn RestorationPhase>>,
73
74 /// `kappa_sigma` for the post-AcceptTrialPoint multiplier reset
75 /// (`IpIpoptAlg.cpp:correct_bound_multiplier`, line 1055-1134).
76 pub kappa_sigma: Number,
77 pub max_iter: Index,
78 /// Initial primal step length offered to the line search at the
79 /// top of each iteration. Mirrors `IpBacktrackingLineSearch`'s
80 /// fraction-to-the-boundary primal step (with τ = `data.curr_tau`).
81 /// In v1.0 the structural value here is 1.0 and the FTB cap is
82 /// applied per-component when the line-search driver computes
83 /// trial slacks; the simplification holds for non-degenerate runs.
84 pub alpha_init: Number,
85 /// Tiny-step relative tolerance — port of upstream
86 /// `IpBacktrackingLineSearch::tiny_step_tol_` (default `10·EPSILON`).
87 /// Step is "tiny" when `max_i |δx_i|/(1+|x_i|) ≤ tiny_step_tol`
88 /// (and same for s, and `c_viol ≤ 1e-4`).
89 pub tiny_step_tol: Number,
90 /// Port of upstream `IpIpoptAlg.cpp` divergence guard: when
91 /// `max_i |x_i|` exceeds this threshold the optimization aborts with
92 /// `SolverReturn::DivergingIterates`. Default `1e20` matches the
93 /// registered `diverging_iterates_tol` option. Catches MESH and
94 /// similar cases where the normal-mode IPM heads off to infinity
95 /// (orig `f` to ±1e33 by iter 90) before line-search failure forces
96 /// a degenerate restoration entry.
97 pub diverging_iterates_tol: Number,
98 /// Companion threshold on the dual step — when both primal and dual
99 /// steps are tiny in two consecutive iterations the algorithm
100 /// declares convergence at the best attainable accuracy. Default
101 /// `1e-2` matches upstream.
102 pub tiny_step_y_tol: Number,
103 /// Set true when the previous iterate was tagged tiny; on the
104 /// second consecutive tiny step the loop sets `data.tiny_step_flag`
105 /// so the mu update can attempt to terminate. Mirrors
106 /// `IpBacktrackingLineSearch::tiny_step_last_iteration_`.
107 pub tiny_step_last_iteration: bool,
108 /// Cycle-detection state for [`Self::invoke_restoration`]: the
109 /// outer `(x, s)` snapshot from the previous restoration entry,
110 /// cleared on any iteration that exits via a normal line-search
111 /// accept. When restoration is invoked twice in a row and the
112 /// outer iterate has not moved between entries (relative
113 /// 2-norm < 1e-10 on both `x` and `s`), the inner resto-IPM is
114 /// returning Recovered points indistinguishable from `curr` — a
115 /// cycle. Surfaces as `ErrorInStepComputation`. Mirrors the
116 /// *intent* of upstream `IpBacktrackingLineSearch.cpp:580-600`'s
117 /// almost-feasible-resto guard while staying robust against the
118 /// `inf_pr` micro-drift seen on ACOPR14 (delta ~3e-12 per entry,
119 /// inf_du essentially constant) where a scalar-`inf_pr` heuristic
120 /// fails. Productive single-restoration sequences (BT8, HIMMELBJ,
121 /// LINSPANH, LSNNODOC, ODFITS, OET3) clear the snapshot via
122 /// `Outcome::Accepted` between entries and are unaffected.
123 last_resto_entry_x: Option<Box<dyn Vector>>,
124 last_resto_entry_s: Option<Box<dyn Vector>>,
125 /// Snapshot of the *recovery* iterate from the previous
126 /// restoration. Compared against the next entry's `(x, s)` to
127 /// detect "outer made no progress between consecutive resto
128 /// invocations". When this distance is below threshold for
129 /// several consecutive entries, terminate — catching
130 /// slow-non-convergence cycles (ACOPR14, TRO3X3, ACOPR30) where
131 /// resto's *inner* moves substantively each call but the *outer*
132 /// makes no progress between calls. Cleared on any LS-accepted
133 /// step.
134 last_resto_recovery_x: Option<Box<dyn Vector>>,
135 last_resto_recovery_s: Option<Box<dyn Vector>>,
136 /// Count of consecutive restoration entries on which the outer
137 /// step (recovery → next-entry) was below the iterate-distance
138 /// threshold. Cleared on any LS-accepted step. Limit chosen to
139 /// let MAKELA3, HAIFAM, HALDMADS, ROBOT, TENBARS2 — which need
140 /// 2-3 consecutive resto entries to recover — pass through.
141 resto_no_outer_progress_count: usize,
142 /// Count of consecutive restoration entries on which the outer
143 /// constraint violation at entry was already below `tol` (the
144 /// outer optimality tolerance). Matches the *intent* of upstream
145 /// `IpBacktrackingLineSearch.cpp:580-600`'s almost-feasible-resto
146 /// guard while using a looser cv threshold (`tol` vs `1e-2·tol`)
147 /// — catches DECONVBNE's resto-thrash where each cycle re-enters
148 /// at cv ≈ 3e-10 < tol with bound multipliers reset to 1, the
149 /// outer's σ-blowup explodes inf_du to 1.9e7, alpha-min triggers
150 /// resto re-entry, and the (inf_pr, inf_du) post-recovery state
151 /// is essentially identical across cycles but `x` drifts enough
152 /// that [`Self::last_resto_recovery_x`]-based detection misses.
153 /// Cumulative (never cleared on LS-accept), since DECONVBNE's
154 /// cycle interleaves R-recoveries with sub-tol accepts that
155 /// accomplish no real outer progress. Fires after 3 near-feasible
156 /// entries — surfaces as `StopAtAcceptablePoint` since the
157 /// recovered point already satisfies constraint feasibility
158 /// within `tol`.
159 resto_near_feasible_count: usize,
160 /// Snapshot of the most recent iterate that the convergence check
161 /// flagged "acceptable" (NLP error ≤ `acceptable_tol`). Mirrors
162 /// upstream `IpBacktrackingLineSearch::acceptable_iterate_`
163 /// (`IpBacktrackingLineSearch.cpp:1286-1310`). Used by
164 /// [`Self::restore_acceptable_point`] to roll back when restoration
165 /// fails — if such an iterate exists, the algorithm exits with
166 /// `SolverReturn::StopAtAcceptablePoint` rather than
167 /// `RestorationFailure`. Cleared/refreshed on every iteration that
168 /// satisfies the acceptable predicate.
169 acceptable_iterate: Option<crate::iterates_vector::IteratesVector>,
170 acceptable_iter_number: Index,
171 /// Shared per-solve diagnostics state. `None` unless the CLI
172 /// requested `--dump <cat>:<spec>`. When set, the outer loop
173 /// advances the state's iter counter and the augmented-system
174 /// solver consults it to gate KKT dumps.
175 diagnostics: Option<Rc<DiagnosticsState>>,
176
177 // ---- Restoration-phase audit counters (pounce#12). ----
178 //
179 // Drained into `SolveStatistics` by `IpoptApplication::optimize_constrained`
180 // after the solve completes. Counts are cumulative across the run.
181 /// Number of `invoke_restoration` entries.
182 pub resto_calls: Index,
183 /// Sum of inner-IPM iter counts across every restoration call.
184 pub resto_inner_iters: Index,
185 /// Number of outer iters that ran in restoration mode (R-line
186 /// equivalents in `print_level=5` output).
187 pub resto_outer_iters: Index,
188 /// Cumulative wall-clock seconds spent inside `perform_restoration`.
189 pub resto_wall_secs: Number,
190
191 // ---- Per-iteration history capture (pounce#8). ----
192 //
193 /// When `true`, [`Self::iterate`] appends an `IterRecord` to
194 /// `iter_history` each step. Off by default — the JSON output
195 /// path in `pounce-cli` opts in.
196 pub record_iter_history: bool,
197 /// Per-iteration trajectory captured when `record_iter_history`
198 /// is on. Drained into [`SolveStatistics::iterations`] by
199 /// `IpoptApplication::optimize_constrained` after the solve.
200 pub iter_history: Vec<pounce_nlp::solve_statistics::IterRecord>,
201 /// When `false`, the per-iteration table that `iterate()` writes
202 /// straight to stdout is suppressed. Wired from
203 /// `IpoptApplication`'s `print_level` option: level 0 turns this
204 /// off (matches upstream's "no console output" contract). Default
205 /// `true` so CLI / direct-driver users keep the familiar trace.
206 pub print_iter_output: bool,
207}
208
209impl IpoptAlgorithm {
210 pub fn new(data: IpoptDataHandle, cq: IpoptCqHandle, mut bundle: AlgorithmBundle) -> Self {
211 // The builder may pre-populate `bundle.search_dir` when given a
212 // `LinearBackendFactory`; lift it onto the algorithm so the
213 // iterate body can call into it directly.
214 let search_dir = bundle.search_dir.take();
215 Self {
216 data,
217 cq,
218 bundle,
219 nlp: None,
220 tnlp: None,
221 search_dir,
222 restoration: None,
223 kappa_sigma: 1e10,
224 max_iter: 3000,
225 alpha_init: 1.0,
226 tiny_step_tol: 10.0 * Number::EPSILON,
227 diverging_iterates_tol: 1e20,
228 tiny_step_y_tol: 1e-2,
229 tiny_step_last_iteration: false,
230 last_resto_entry_x: None,
231 last_resto_entry_s: None,
232 last_resto_recovery_x: None,
233 last_resto_recovery_s: None,
234 resto_no_outer_progress_count: 0,
235 resto_near_feasible_count: 0,
236 acceptable_iterate: None,
237 acceptable_iter_number: 0,
238 diagnostics: None,
239 resto_calls: 0,
240 resto_inner_iters: 0,
241 resto_outer_iters: 0,
242 resto_wall_secs: 0.0,
243 record_iter_history: false,
244 iter_history: Vec::new(),
245 print_iter_output: true,
246 }
247 }
248
249 /// Stash the current iterate as the "last acceptable" backup —
250 /// port of `IpBacktrackingLineSearch::StoreAcceptablePoint`
251 /// (`IpBacktrackingLineSearch.cpp:1286-1293`).
252 fn store_acceptable_point(&mut self) {
253 let d = self.data.borrow();
254 if let Some(curr) = d.curr.as_ref() {
255 self.acceptable_iterate = Some(curr.clone());
256 self.acceptable_iter_number = d.iter_count;
257 }
258 }
259
260 /// Roll the iterate back to the last acceptable snapshot — port of
261 /// `IpBacktrackingLineSearch::RestoreAcceptablePoint`
262 /// (`IpBacktrackingLineSearch.cpp:1295-1310`). Returns `true` if a
263 /// snapshot was available and applied; `false` otherwise (caller
264 /// then surfaces the original failure status).
265 fn restore_acceptable_point(&mut self) -> bool {
266 let Some(prev) = self.acceptable_iterate.clone() else {
267 return false;
268 };
269 let mut d = self.data.borrow_mut();
270 d.set_trial(prev);
271 // `accept_trial_point` promotes `trial → curr`, mirroring the
272 // upstream sequence `set_trial(...); AcceptTrialPoint();`.
273 d.accept_trial_point();
274 true
275 }
276
277 pub fn with_nlp(mut self, nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
278 self.nlp = Some(nlp);
279 self
280 }
281
282 /// Install a user-facing TNLP handle. Enables per-iteration
283 /// `TNLP::intermediate_callback` invocation from `optimize()`.
284 pub fn with_tnlp(mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
285 self.tnlp = Some(tnlp);
286 self
287 }
288
289 /// Build an [`IterStats`] payload from the current `IpoptData` /
290 /// `IpoptCq` state. Mirrors the field set the upstream Ipopt main
291 /// loop hands to `IntermediateCallback` after each `AcceptTrialPoint`.
292 fn build_iter_stats(&self) -> IterStats {
293 let d = self.data.borrow();
294 let c = self.cq.borrow();
295 let dnrm = match d.delta.as_ref() {
296 Some(delta) => delta.x.amax().max(delta.s.amax()),
297 None => 0.0,
298 };
299 IterStats {
300 // alg_mod tracking (regular vs restoration) is a follow-up;
301 // every callback fire from the outer loop reports RegularMode.
302 mode: AlgorithmMode::RegularMode,
303 iter: d.iter_count,
304 obj_value: c.curr_f(),
305 inf_pr: c.curr_primal_infeasibility_max(),
306 inf_du: c.curr_dual_infeasibility_max(),
307 mu: d.curr_mu,
308 d_norm: dnrm,
309 regularization_size: d.info_regu_x,
310 alpha_du: d.info_alpha_dual,
311 alpha_pr: d.info_alpha_primal,
312 ls_trials: d.info_ls_count,
313 }
314 }
315
316 /// Fire `TNLP::intermediate_callback` if a TNLP handle and NLP
317 /// handle are installed. Wraps the call in an [`IntermediateContext`]
318 /// guard so downstream inspector entry points (the C API's
319 /// `GetIpoptCurrent*`) can read live state for the duration. Returns
320 /// `true` to continue, `false` if the user requested termination.
321 fn fire_intermediate(&self) -> bool {
322 let Some(tnlp) = self.tnlp.as_ref() else {
323 return true;
324 };
325 let Some(nlp) = self.nlp.as_ref() else {
326 return true;
327 };
328 let stats = self.build_iter_stats();
329 let _guard = CtxGuard::install(IntermediateContext {
330 data: Rc::clone(&self.data),
331 cq: Rc::clone(&self.cq),
332 nlp: Rc::clone(nlp),
333 });
334 tnlp.borrow_mut().intermediate_callback(
335 stats,
336 &TnlpIpoptData::default(),
337 &TnlpIpoptCq::default(),
338 )
339 }
340
341 pub fn with_search_dir(mut self, sd: PdSearchDirCalc) -> Self {
342 self.search_dir = Some(sd);
343 self
344 }
345
346 pub fn with_restoration(mut self, resto: Box<dyn RestorationPhase>) -> Self {
347 self.restoration = Some(resto);
348 self
349 }
350
351 /// Install the shared diagnostics state. The state is propagated
352 /// to the augmented-system solver at the top of [`Self::optimize`]
353 /// so dump sites can consult per-iter gating.
354 pub fn with_diagnostics(mut self, diag: Rc<DiagnosticsState>) -> Self {
355 self.diagnostics = Some(diag);
356 self
357 }
358
359 /// One iteration body — port of `Optimize()`'s inner loop.
360 /// Returns either `Continue` to keep iterating or a terminal
361 /// [`SolverReturn`] mirroring upstream's exception → return-code
362 /// translation table (see `MAIN_LOOP.md` §"Exception mapping").
363 fn iterate(&mut self) -> IterateOutcome {
364 // Shared timing accumulator — cheap Rc clone so each phase can
365 // bump its own counter without re-borrowing `data`.
366 let timing = self.data.borrow().timing.clone();
367
368 // 1. Output iteration row. Header every 10 iters; the row
369 // itself is built by the strategy and printed here so a
370 // long-running solve gives the user feedback. (Phase-7
371 // upstream routes this through the journalist; until that
372 // surface lands, write straight to stdout.)
373 //
374 // Print BEFORE `reset_info` so the row reflects the
375 // accepted step from the previous iteration (alphas, ls
376 // count, alpha_char), matching upstream's
377 // `IpIpoptAlgorithm::Optimize` ordering.
378 timing.output_iteration.start();
379 self.bundle.iter_output.write_output();
380 if self.print_iter_output {
381 let iter_count = self.data.borrow().iter_count;
382 if iter_count % 10 == 0 {
383 print!("{}", crate::output::orig::OrigIterationOutput::HEADER);
384 }
385 let row = self.bundle.iter_output.format_row(&self.data, &self.cq);
386 println!("{row}");
387 }
388 timing.output_iteration.end();
389
390 // Optional per-iteration history capture (pounce#8 / JSON
391 // output). Fires alongside the console print so the records
392 // are always in lock-step with what the user sees on stdout.
393 if self.record_iter_history {
394 let d = self.data.borrow();
395 let c = self.cq.borrow();
396 let iter = d.iter_count;
397 let inf_pr = c.curr_primal_infeasibility_max();
398 let inf_du = c.curr_dual_infeasibility_max();
399 let mu = d.curr_mu;
400 let d_norm = match &d.delta {
401 Some(delta) => delta.x.amax().max(delta.s.amax()),
402 None => 0.0,
403 };
404 let regularization = d.info_regu_x;
405 let alpha_dual = d.info_alpha_dual;
406 let alpha_primal = d.info_alpha_primal;
407 let alpha_primal_char = d.info_alpha_primal_char;
408 let ls_trials = d.info_ls_count;
409 let objective = c.unscaled_curr_f();
410 drop(d);
411 drop(c);
412 self.iter_history
413 .push(pounce_nlp::solve_statistics::IterRecord {
414 iter,
415 objective,
416 inf_pr,
417 inf_du,
418 mu,
419 d_norm,
420 regularization,
421 alpha_dual,
422 alpha_primal,
423 alpha_primal_char,
424 ls_trials,
425 });
426 }
427
428 // Reset per-iteration info on data (after printing previous
429 // iter's accepted-step info; before the next line search).
430 self.data.borrow_mut().reset_info();
431
432 // 2. Convergence check.
433 timing.check_convergence.start();
434 let nlp_err = self.cq.borrow().curr_nlp_error();
435 let iter_count = self.data.borrow().iter_count;
436 if !nlp_err.is_finite() {
437 timing.check_convergence.end();
438 return IterateOutcome::Terminate(SolverReturn::InvalidNumberDetected);
439 }
440 // Divergence guard — port of upstream `IpIpoptAlg.cpp` post-
441 // AcceptTrialPoint check. When `max_i |x_i|` exceeds the
442 // registered `diverging_iterates_tol` (default `1e20`), exit
443 // cleanly with `DivergingIterates` rather than spiralling into
444 // a degenerate restoration whose inner sub-NLP can't recover
445 // (MESH: orig `f` already at -3.6e33 by iter 90, restoration
446 // entered too late to bound `x`).
447 if let Some(curr) = self.data.borrow().curr.as_ref() {
448 if curr.x.amax() > self.diverging_iterates_tol {
449 timing.check_convergence.end();
450 return IterateOutcome::Terminate(SolverReturn::DivergingIterates);
451 }
452 }
453 let conv_status = self
454 .bundle
455 .conv_check
456 .check_convergence_with_state(nlp_err, iter_count, &self.data, &self.cq);
457 match conv_status {
458 ConvergenceStatus::Continue => {}
459 ConvergenceStatus::Converged => {
460 timing.check_convergence.end();
461 return IterateOutcome::Terminate(SolverReturn::Success);
462 }
463 ConvergenceStatus::ConvergedToAcceptable => {
464 timing.check_convergence.end();
465 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
466 }
467 ConvergenceStatus::MaxIterExceeded => {
468 timing.check_convergence.end();
469 return IterateOutcome::Terminate(SolverReturn::MaxiterExceeded);
470 }
471 ConvergenceStatus::CpuTimeExceeded => {
472 timing.check_convergence.end();
473 return IterateOutcome::Terminate(SolverReturn::CpuTimeExceeded);
474 }
475 ConvergenceStatus::WallTimeExceeded => {
476 timing.check_convergence.end();
477 return IterateOutcome::Terminate(SolverReturn::WallTimeExceeded);
478 }
479 ConvergenceStatus::LocallyInfeasible => {
480 timing.check_convergence.end();
481 return IterateOutcome::Terminate(SolverReturn::LocalInfeasibility);
482 }
483 ConvergenceStatus::Failed => {
484 timing.check_convergence.end();
485 return IterateOutcome::Terminate(SolverReturn::InternalError);
486 }
487 }
488
489 // Stash the iterate if it satisfies the per-component
490 // `acceptable_*_tol` triplet. Mirrors upstream
491 // `IpBacktrackingLineSearch.cpp:282-289` — checked at the top
492 // of every line-search call so the most recent acceptable
493 // iterate is always available as a rollback target if
494 // restoration later fails. The recorder feeds
495 // `acceptable_obj_change_tol`'s stability cross-check on
496 // subsequent iterates.
497 if self
498 .bundle
499 .conv_check
500 .current_is_acceptable_with_state(nlp_err, &self.data, &self.cq)
501 {
502 self.store_acceptable_point();
503 let curr_f = self.cq.borrow().curr_f();
504 self.bundle.conv_check.set_curr_acceptable_obj(curr_f);
505 }
506 timing.check_convergence.end();
507
508 // 3. Hessian update. Must run BEFORE `update_barrier_parameter`
509 // so the adaptive-μ oracles (probing, quality-function) drive
510 // their affine/centering solves against `W(curr_N)`, not the
511 // stale `W(curr_{N-1})` left in `data.w` by the previous iter's
512 // tail-end Hessian update. Upstream calls `UpdateHessian()`
513 // first in every main-loop body (`IpIpoptAlg.cpp:386`); pounce
514 // previously reordered this to the tail, which made iters 1+
515 // pick μ from the prior iterate's Hessian on adaptive-mu +
516 // quality-function — visible on CRESC50 as a catastrophic
517 // early-iter divergence (theta=5.8e5 by iter 61 vs upstream
518 // never entering restoration).
519 timing.update_hessian.start();
520 let _ = self.bundle.hess.update_hessian(&self.data, &self.cq);
521 timing.update_hessian.end();
522
523 // 4. Barrier parameter. Pass nlp + search_dir through so the
524 // adaptive μ oracles (probing, quality-function) can drive
525 // their own affine-step solves; monotone ignores them.
526 // Snapshot the tiny-step flag (set by the previous iteration's
527 // tiny-step branch) and the entry mu — if μ can't reduce while
528 // the flag is on, upstream `IpMonotoneMuUpdate.cpp:158-161`
529 // throws TINY_STEP_DETECTED → STOP_AT_TINY_STEP, which we
530 // realise as a clean termination here. Only the monotone update
531 // throws: `IpAdaptiveMuUpdate.cpp` consumes the tiny-step flag
532 // via its `force_no_progress` path (fix μ, keep iterating), so
533 // the termination is gated on `terminates_on_tiny_step()`.
534 timing.update_barrier_parameter.start();
535 let tiny_at_entry = self.data.borrow().tiny_step_flag;
536 let mu_before = self.data.borrow().curr_mu;
537 let mu_terminates_on_tiny = self.bundle.mu_update.terminates_on_tiny_step();
538 let next_mu = self.bundle.mu_update.update_barrier_parameter(
539 &self.data,
540 &self.cq,
541 self.nlp.as_ref(),
542 self.search_dir.as_mut(),
543 );
544 self.data.borrow_mut().curr_mu = next_mu;
545 timing.update_barrier_parameter.end();
546 if tiny_at_entry && mu_terminates_on_tiny && (next_mu - mu_before).abs() < Number::EPSILON {
547 return IterateOutcome::Terminate(SolverReturn::StopAtTinyStep);
548 }
549
550 // Mirror upstream `IpAdaptiveMuUpdate.cpp:339, 386, 431` and
551 // `IpMonotoneMuUpdate.cpp:165`: every code path that *changes*
552 // μ calls `linesearch_->Reset()`, which clears the filter via
553 // `FilterLSAcceptor::Reset` (`IpFilterLSAcceptor.cpp:524-532`).
554 // Rationale: filter entries are computed against the current
555 // barrier — when μ changes, prior entries no longer apply and
556 // would over-constrain acceptance. The two upstream paths that
557 // do NOT reset (stay-fixed-no-decrease and fixed→free transition)
558 // both keep μ at curr_mu, so the `mu_changed` check captures
559 // the intended distinction.
560 if next_mu != mu_before {
561 self.bundle.line_search.reset();
562 }
563
564 // 5. Search direction. Skipped without an NLP + search_dir.
565 // (Hessian was updated in step 3 above before the barrier-μ
566 // oracle so that adaptive-μ uses W(curr_N), not stale W.)
567 if let (Some(nlp), Some(sd)) = (self.nlp.as_ref(), self.search_dir.as_mut()) {
568 timing.compute_search_direction.start();
569 let ok = sd.compute_search_direction(&self.data, &self.cq, nlp);
570 timing.compute_search_direction.end();
571 if !ok {
572 // Mirror upstream `IpIpoptAlg.cpp:417-430`: a failed
573 // step computation puts the algorithm in emergency
574 // mode, which calls `BacktrackingLineSearch::
575 // ActivateFallbackMechanism` (cpp:1312-1328). When a
576 // restoration phase is configured, the next pass of
577 // `ComputeAcceptableTrialPoint` sees `goto_resto` at
578 // cpp:299-306 and hands control to restoration. Only
579 // when neither restoration nor an acceptor-level
580 // fallback is available does upstream throw
581 // `STEP_COMPUTATION_FAILED`.
582 if self.restoration.is_some() {
583 return self.invoke_restoration();
584 }
585 return IterateOutcome::Terminate(SolverReturn::ErrorInStepComputation);
586 }
587 if std::env::var_os("POUNCE_DBG_DELTA").is_some() {
588 let d = self.data.borrow();
589 let it = d.iter_count;
590 if let Some(delta) = d.delta.as_ref() {
591 use crate::iterates_vector::IteratesVector;
592 use pounce_linalg::{compound_vector::CompoundVector, Vector};
593 let dv: &IteratesVector = delta;
594 eprintln!(
595 "[PN_DELTA] iter={} mu={:.6e} dx_amax={:.6e} ds_amax={:.6e} dyc_amax={:.6e} dyd_amax={:.6e} dzL_amax={:.6e} dzU_amax={:.6e} dvL_amax={:.6e} dvU_amax={:.6e}",
596 it, d.curr_mu,
597 dv.x.amax(), dv.s.amax(), dv.y_c.amax(), dv.y_d.amax(),
598 dv.z_l.amax(), dv.z_u.amax(), dv.v_l.amax(), dv.v_u.amax()
599 );
600 if let Some(cdx) = dv.x.as_any().downcast_ref::<CompoundVector>() {
601 eprintln!(
602 "[PN_DELTA] iter={} dx_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
603 it,
604 cdx.comp(0).amax(),
605 cdx.comp(1).amax(),
606 cdx.comp(2).amax(),
607 cdx.comp(3).amax(),
608 cdx.comp(4).amax(),
609 );
610 eprintln!(
611 "[PN_DELTA] iter={} dx_blocks_nrm2: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
612 it,
613 cdx.comp(0).nrm2(),
614 cdx.comp(1).nrm2(),
615 cdx.comp(2).nrm2(),
616 cdx.comp(3).nrm2(),
617 cdx.comp(4).nrm2(),
618 );
619 eprintln!(
620 "[PN_DELTA] iter={} dx_blocks_asum: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
621 it,
622 cdx.comp(0).asum(),
623 cdx.comp(1).asum(),
624 cdx.comp(2).asum(),
625 cdx.comp(3).asum(),
626 cdx.comp(4).asum(),
627 );
628 // Argmax of orig block via dot with sign — print first few values.
629 if let Some(dv_orig) =
630 cdx.comp(0)
631 .as_any()
632 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
633 {
634 let v = dv_orig.values();
635 let mut imax = 0usize;
636 let mut amax = 0.0f64;
637 for (i, &x) in v.iter().enumerate() {
638 if x.abs() > amax {
639 amax = x.abs();
640 imax = i;
641 }
642 }
643 eprintln!(
644 "[PN_DELTA] iter={} dx_orig argmax: i={} v={:.17e} (n={})",
645 it,
646 imax,
647 v[imax],
648 v.len()
649 );
650 }
651 }
652 let p = &d.perturbations;
653 eprintln!(
654 "[PN_DELTA] iter={} pert: dx={:.6e} ds={:.6e} dc={:.6e} dd={:.6e}",
655 it, p.delta_x, p.delta_s, p.delta_c, p.delta_d
656 );
657 drop(d);
658 let cq = self.cq.borrow();
659 let gf = cq.curr_grad_f();
660 let gl = cq.curr_grad_lag_x();
661 let cc = cq.curr_c();
662 let cd = cq.curr_d_minus_s();
663 let sx = cq.curr_sigma_x();
664 let ss = cq.curr_sigma_s();
665 eprintln!(
666 "[PN_DELTA] iter={} cq: gradf_amax={:.6e} gradf_nrm2={:.6e} gradlag_amax={:.6e} gradlag_nrm2={:.6e} c_amax={:.6e} c_nrm2={:.6e} d_amax={:.6e} d_nrm2={:.6e} sigx_amax={:.6e} sigx_nrm2={:.6e} sigs_amax={:.6e} sigs_nrm2={:.6e}",
667 it,
668 gf.amax(), gf.nrm2(),
669 gl.amax(), gl.nrm2(),
670 cc.amax(), cc.nrm2(),
671 cd.amax(), cd.nrm2(),
672 sx.amax(), sx.nrm2(),
673 ss.amax(), ss.nrm2(),
674 );
675 if let Some(cgf) = gf.as_any().downcast_ref::<CompoundVector>() {
676 eprintln!(
677 "[PN_DELTA] iter={} gradf_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
678 it,
679 cgf.comp(0).amax(),
680 cgf.comp(1).amax(),
681 cgf.comp(2).amax(),
682 cgf.comp(3).amax(),
683 cgf.comp(4).amax(),
684 );
685 }
686 if let Some(curr) = self.data.borrow().curr.clone() {
687 eprintln!(
688 "[PN_DELTA] iter={} bound_mults: zL_amax={:.6e} zU_amax={:.6e} vL_amax={:.6e} vU_amax={:.6e} s_amax={:.6e} s_nrm2={:.6e} x_amax={:.6e} x_nrm2={:.6e}",
689 it,
690 curr.z_l.amax(), curr.z_u.amax(),
691 curr.v_l.amax(), curr.v_u.amax(),
692 curr.s.amax(), curr.s.nrm2(),
693 curr.x.amax(), curr.x.nrm2(),
694 );
695 if let Some(czl) = curr.z_l.as_any().downcast_ref::<CompoundVector>() {
696 eprintln!(
697 "[PN_DELTA] iter={} zL_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
698 it,
699 czl.comp(0).amax(),
700 czl.comp(1).amax(),
701 czl.comp(2).amax(),
702 czl.comp(3).amax(),
703 czl.comp(4).amax(),
704 );
705 }
706 if let Some(czu) = curr.z_u.as_any().downcast_ref::<CompoundVector>() {
707 eprintln!("[PN_DELTA] iter={} zU_ncomps={}", it, czu.n_comps());
708 for ic in 0..czu.n_comps() {
709 eprintln!(
710 "[PN_DELTA] iter={} zU_block[{}]_amax={:.6e} dim={}",
711 it,
712 ic,
713 czu.comp(ic).amax(),
714 czu.comp(ic).dim()
715 );
716 }
717 }
718 }
719 if let Some(csx) = sx.as_any().downcast_ref::<CompoundVector>() {
720 eprintln!(
721 "[PN_DELTA] iter={} sigx_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
722 it,
723 csx.comp(0).amax(),
724 csx.comp(1).amax(),
725 csx.comp(2).amax(),
726 csx.comp(3).amax(),
727 csx.comp(4).amax(),
728 );
729 }
730 drop(cq);
731 let d = self.data.borrow();
732 // Also dump curr.x_orig argmax
733 if let Some(curr) = d.curr.as_ref() {
734 if let Some(cx) = curr.x.as_any().downcast_ref::<CompoundVector>() {
735 if let Some(xo) =
736 cx.comp(0)
737 .as_any()
738 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
739 {
740 let v = xo.values();
741 let mut imax = 0usize;
742 let mut amax = 0.0f64;
743 for (i, &x) in v.iter().enumerate() {
744 if x.abs() > amax {
745 amax = x.abs();
746 imax = i;
747 }
748 }
749 eprintln!("[PN_DELTA] iter={} curr_x_orig argmax: i={} v={:.17e} amax={:.17e} nrm2={:.17e}",
750 it, imax, v[imax], xo.amax(), xo.nrm2());
751 }
752 }
753 }
754 }
755 }
756 }
757
758 // 6. Acceptable trial point — run the line search if we have a
759 // primal/dual step on `data.delta`. Wrap in a guard so all
760 // early-return paths (ErrorInStepComputation, InternalError,
761 // restoration entry) still stop the timer.
762 let _ls_guard = timing.compute_acceptable_trial_point.guard();
763 let have_delta = self.data.borrow().delta.is_some();
764 if have_delta {
765 let delta = match self.data.borrow().delta.as_ref().cloned() {
766 Some(d) => d,
767 None => {
768 return IterateOutcome::Terminate(SolverReturn::ErrorInStepComputation);
769 }
770 };
771 // Cap alpha by the primal fraction-to-the-boundary so the
772 // first trial cannot push slacks past their bounds, and by
773 // the dual FTB so bound multipliers stay positive. Mirrors
774 // upstream `IpBacktrackingLineSearch::FindAcceptableTrialPoint`'s
775 // calls to `IpCq.primal_frac_to_the_bound` /
776 // `IpCq.dual_frac_to_the_bound` with τ = `curr_tau`.
777 let tau = self.data.borrow().curr_tau;
778 let alpha_p_max = self.cq.borrow().aff_step_alpha_primal_max(&delta, tau);
779 let alpha_d_max = self.cq.borrow().aff_step_alpha_dual_max(&delta, tau);
780
781 // Tiny-step gate — port of `IpBacktrackingLineSearch.cpp:363`
782 // and the handling block at lines 382-435. When the search
783 // direction is so small that any nonzero α would just
784 // bounce inside floating-point noise, take the FTB step
785 // unchecked and skip the line search; that's the only way
786 // to hit `STOP_AT_TINY_STEP` cleanly when the iterate is
787 // already at a converged point but `nlp_error > tol` due to
788 // scaling or unbounded duals.
789 if self.detect_tiny_step(&delta) {
790 let alpha_p = alpha_p_max;
791 let alpha_d = alpha_d_max;
792 let curr = match self.data.borrow().curr.clone() {
793 Some(c) => c,
794 None => return IterateOutcome::Terminate(SolverReturn::InternalError),
795 };
796 let trial_iv = scaled_step_unchecked(&curr, &delta, alpha_p, alpha_d);
797 {
798 let mut d = self.data.borrow_mut();
799 d.set_trial(trial_iv);
800 d.info_alpha_primal = alpha_p;
801 d.info_alpha_dual = alpha_d;
802 d.info_ls_count = 0;
803 if self.tiny_step_last_iteration {
804 d.info_alpha_primal_char = 'T';
805 d.tiny_step_flag = true;
806 } else {
807 d.info_alpha_primal_char = 't';
808 }
809 }
810 let dy_amax = delta.y_c.amax().max(delta.y_d.amax());
811 self.tiny_step_last_iteration = dy_amax < self.tiny_step_y_tol;
812 } else {
813 self.tiny_step_last_iteration = false;
814 let alpha_init = self.alpha_init.min(alpha_p_max);
815 let alpha_dual = self.alpha_init.min(alpha_d_max);
816 let outcome = self.bundle.line_search.find_acceptable_trial_point(
817 &self.data,
818 &self.cq,
819 &delta,
820 alpha_init,
821 alpha_dual,
822 self.nlp.as_ref(),
823 self.search_dir.as_mut(),
824 );
825 match outcome {
826 Outcome::Accepted => {
827 // A normal LS-accepted step breaks any in-flight
828 // restoration cycle — clear the cycle detector
829 // so the next resto entry starts fresh.
830 self.last_resto_entry_x = None;
831 self.last_resto_entry_s = None;
832 self.last_resto_recovery_x = None;
833 self.last_resto_recovery_s = None;
834 self.resto_no_outer_progress_count = 0;
835 // Intentionally *not* clearing
836 // `resto_near_feasible_count` here: DECONVBNE's
837 // cycle interleaves R-recoveries with 2-3
838 // LS-accepted 'f'/'h' steps (which return
839 // `Outcome::Accepted` but accomplish no real
840 // outer progress — alpha drops to 1e-6 and
841 // inf_du remains pinned at 1.9e7), so resetting
842 // on every accept would zero the counter every
843 // cycle and never fire. The counter persists
844 // for the duration of the run and trips after
845 // 3 cumulative near-feasible entries; legitimate
846 // solves enter resto at most once at near-
847 // feasibility (POLAK6, HAIFAM) and stay under
848 // the limit.
849 }
850 Outcome::TinyStep | Outcome::Failed => {
851 // Upstream `IpBacktrackingLineSearch.cpp` raises
852 // `LINE_SEARCH_FAILED` when α drops below
853 // `alpha_min` or all retries reject, which in
854 // turn triggers `ActivateLineSearch` →
855 // restoration.
856 return self.invoke_restoration();
857 }
858 }
859 }
860 }
861
862 // End the line-search/trial timer here so the bookkeeping in
863 // steps 7-8 below is attributed to `accept_trial_point` (which
864 // mirrors upstream's split: filter update and FTB reset are
865 // accept-side, not line-search-side).
866 _ls_guard.stop();
867
868 // 7. Accept trial point (promotes `trial` to `curr` if set).
869 // The acceptor's filter has already been augmented (when
870 // appropriate) inside `find_acceptable_trial_point` via
871 // `update_for_next_iteration`, mirroring upstream's call
872 // chain in `IpBacktrackingLineSearch.cpp:839`.
873 let _accept_guard = timing.accept_trial_point.guard();
874 self.data.borrow_mut().accept_trial_point();
875
876 // 8. Bound multiplier kappa_sigma reset.
877 self.correct_bound_multiplier();
878
879 IterateOutcome::Continue
880 }
881
882 /// Port of `IpBacktrackingLineSearch::DetectTinyStep`
883 /// (`IpBacktrackingLineSearch.cpp:1219-1278`). Returns true iff
884 /// `max_i |δx_i|/(1+|x_i|) ≤ tiny_step_tol`,
885 /// `max_i |δs_i|/(1+|s_i|) ≤ tiny_step_tol`, AND
886 /// `curr_constraint_violation ≤ 1e-4`. Disabled when
887 /// `tiny_step_tol == 0`.
888 fn detect_tiny_step(&self, delta: &crate::iterates_vector::IteratesVector) -> bool {
889 if self.tiny_step_tol == 0.0 {
890 return false;
891 }
892 let curr = match self.data.borrow().curr.clone() {
893 Some(c) => c,
894 None => return false,
895 };
896
897 // |x_i|+1
898 let mut tmp = curr.x.make_new_copy();
899 tmp.element_wise_abs();
900 tmp.add_scalar(1.0);
901 // |δx_i|/(|x_i|+1) ; checked via Amax of (δx ./ (|x|+1)).
902 let mut tmp2 = delta.x.make_new_copy();
903 tmp2.element_wise_divide(&*tmp);
904 if tmp2.amax() > self.tiny_step_tol {
905 return false;
906 }
907
908 if curr.s.dim() > 0 {
909 let mut tmp = curr.s.make_new_copy();
910 tmp.element_wise_abs();
911 tmp.add_scalar(1.0);
912 let mut tmp2 = delta.s.make_new_copy();
913 tmp2.element_wise_divide(&*tmp);
914 if tmp2.amax() > self.tiny_step_tol {
915 return false;
916 }
917 }
918
919 let cviol = self.cq.borrow().curr_constraint_violation();
920 if cviol > 1e-4 {
921 return false;
922 }
923 true
924 }
925
926 /// Drive the restoration phase after a line-search failure.
927 /// Returns `IterateOutcome::Continue` if the restoration driver
928 /// recovered (the algorithm carries on from the recovered iterate);
929 /// otherwise terminates with [`SolverReturn::RestorationFailure`].
930 /// Mirrors upstream's
931 /// `IpBacktrackingLineSearch::ActivateLineSearch` → `PerformRestoration`
932 /// chain.
933 fn invoke_restoration(&mut self) -> IterateOutcome {
934 // Snapshot the outer reference iterate's `(theta, barr)` and
935 // build the orig-progress callback the inner IPM will consult
936 // at every iteration (mirrors upstream
937 // `IpRestoFilterConvCheck::SetOrigLSAcceptor` plus
938 // `IpFilterLSAcceptor::Reset`'s `reference_*_` snapshot).
939 let reference_theta = self.cq.borrow().curr_constraint_violation();
940 let reference_barr = self.cq.borrow().curr_barrier_obj();
941
942 if std::env::var("POUNCE_DBG_RESTO").is_ok() {
943 let iter = self.data.borrow().iter_count;
944 eprintln!(
945 "RESTO_ENTRY iter={} theta={:.6e} barr={:.6e} near_feas_ct={}",
946 iter, reference_theta, reference_barr, self.resto_near_feasible_count,
947 );
948 }
949
950 // No-progress restoration cycle detector. Two layered checks
951 // surface as `ErrorInStepComputation` instead of cycling to
952 // `max_iter` exhaustion (mirrors the *intent* of upstream
953 // `IpBacktrackingLineSearch.cpp:580-600`'s almost-feasible
954 // resto guard):
955 //
956 // 1. *Static cycle*: entry-to-entry — when the curr `(x, s)`
957 // at this entry is essentially identical to the snapshot
958 // from the previous entry, the inner resto-IPM is
959 // returning recovered iterates indistinguishable from
960 // entry, AND the outer didn't move either. Fires
961 // immediately. Catches QCNEW, EQC, MESH, POLAK6, S365,
962 // S365MOD, SIPOW2M, PFIT4.
963 //
964 // 2. *Slow-progress cycle*: recovery-to-entry — when curr at
965 // this entry is essentially identical to the *recovery*
966 // iterate from the previous resto, the outer made no
967 // progress between resto invocations even though resto's
968 // inner moved substantively. Counted, fires after 5
969 // consecutive entries. Catches ACOPR14, ACOPR30, TRO3X3
970 // while letting MAKELA3, HAIFAM, HALDMADS, ROBOT,
971 // TENBARS2 — which need 2-3 productive resto entries
972 // before LS accepts — pass through.
973 //
974 // A productive single-restoration sequence (BT8, HIMMELBJ,
975 // LINSPANH, LSNNODOC, ODFITS, OET3) clears both snapshots via
976 // `Outcome::Accepted` between entries and is unaffected.
977 let curr = self
978 .data
979 .borrow()
980 .curr
981 .as_ref()
982 .expect("curr set before invoke_restoration")
983 .clone();
984 // Helper: when the cycle detector fires and the orig cv is
985 // bounded away from outer tol (e.g. PFIT1's 2.73e-2), the
986 // outer is stuck at a feasibility-stationary point and the
987 // honest exit is `LocalInfeasibility`. Below that threshold
988 // the failure is numerical, not algorithmic, so retain
989 // `ErrorInStepComputation`.
990 let outer_tol_for_cycle = self.bundle.conv_check.tol_or_default();
991 let cycle_exit = if reference_theta > (100.0 * outer_tol_for_cycle).max(1e-4) {
992 SolverReturn::LocalInfeasibility
993 } else {
994 SolverReturn::ErrorInStepComputation
995 };
996 if let (Some(prev_x), Some(prev_s)) = (
997 self.last_resto_entry_x.as_ref(),
998 self.last_resto_entry_s.as_ref(),
999 ) {
1000 let dx_rel = relative_distance(&*curr.x, &**prev_x);
1001 let ds_rel = relative_distance(&*curr.s, &**prev_s);
1002 if std::env::var_os("POUNCE_DBG_RESTO_CYCLE").is_some() {
1003 eprintln!(
1004 "[PN_RESTO_CYCLE] entry-vs-entry dx_rel={:.6e} ds_rel={:.6e}",
1005 dx_rel, ds_rel
1006 );
1007 }
1008 if dx_rel <= 1e-10 && ds_rel <= 1e-10 {
1009 return IterateOutcome::Terminate(cycle_exit);
1010 }
1011 }
1012 if let (Some(prev_x), Some(prev_s)) = (
1013 self.last_resto_recovery_x.as_ref(),
1014 self.last_resto_recovery_s.as_ref(),
1015 ) {
1016 let dx_rel = relative_distance(&*curr.x, &**prev_x);
1017 let ds_rel = relative_distance(&*curr.s, &**prev_s);
1018 if std::env::var_os("POUNCE_DBG_RESTO_CYCLE").is_some() {
1019 eprintln!(
1020 "[PN_RESTO_CYCLE] entry-vs-recovery dx_rel={:.6e} ds_rel={:.6e} count={}",
1021 dx_rel, ds_rel, self.resto_no_outer_progress_count
1022 );
1023 }
1024 if dx_rel <= 1e-10 && ds_rel <= 1e-10 {
1025 self.resto_no_outer_progress_count =
1026 self.resto_no_outer_progress_count.saturating_add(1);
1027 // 10-strike limit: tuned to give OET7-style traces room
1028 // to break through (inner inf_pr still decreasing across
1029 // strikes) while still bounding DECONVBNE-style cycles
1030 // (which need a guard but tolerate a wider window —
1031 // ~3 outer steps per cycle, so 10 strikes ≈ 30 outer
1032 // iters, well below the 2987-iter pathological run).
1033 if self.resto_no_outer_progress_count >= 10 {
1034 return IterateOutcome::Terminate(cycle_exit);
1035 }
1036 } else {
1037 self.resto_no_outer_progress_count = 0;
1038 }
1039 }
1040 // Near-feasible resto re-entry detector — matches the *intent*
1041 // of upstream `IpBacktrackingLineSearch.cpp:580-600`'s almost-
1042 // feasible-resto guard with a looser cv threshold. When the
1043 // outer enters restoration with the constraint violation
1044 // already at or below `tol`, the resto sub-IPM will produce a
1045 // recovered iterate that's at most marginally more feasible,
1046 // and any post-recovery σ-blowup from the next outer KKT solve
1047 // will re-trigger resto on the next iteration. Counting these
1048 // entries surfaces the cycle as `StopAtAcceptablePoint` —
1049 // primal feasibility is already met, only the dual residual
1050 // remains. Catches DECONVBNE: pounce ran 2987 iters before
1051 // this guard (cycle of ~30-inner-resto + 3 outer per cycle);
1052 // upstream solves in 505 iters via a different x trajectory.
1053 // Single-entry productive restos (BT8, HIMMELBJ, ODFITS) and
1054 // sub-tol-but-recoverable starts pass through under the 3-
1055 // strike limit.
1056 let outer_tol = self.bundle.conv_check.tol_or_default();
1057 if reference_theta <= outer_tol {
1058 self.resto_near_feasible_count = self.resto_near_feasible_count.saturating_add(1);
1059 if self.resto_near_feasible_count >= 3 {
1060 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
1061 }
1062 } else {
1063 self.resto_near_feasible_count = 0;
1064 }
1065 self.last_resto_entry_x = Some(curr.x.make_new_copy());
1066 self.last_resto_entry_s = Some(curr.s.make_new_copy());
1067
1068 // Augment the outer's filter with the resto-entry envelope —
1069 // mirrors upstream `IpBacktrackingLineSearch.cpp:566`:
1070 // `acceptor_->PrepareRestoPhaseStart()`. Adds
1071 // `((1-γ_θ)·θ_entry, φ_entry - γ_φ·θ_entry)` to the filter so
1072 // that after restoration recovers, the outer's Newton step is
1073 // forced by the filter to make real progress vs the entry
1074 // point. Without this, the outer accepts null-progress 'h'
1075 // steps and re-enters restoration on the next iteration (root
1076 // cause of DECONVBNE's 323 R-accepts vs ipopt's 21).
1077 self.bundle
1078 .line_search
1079 .acceptor_mut()
1080 .prepare_resto_phase_start(reference_theta, reference_barr);
1081
1082 let orig_progress_cb = self.bundle.line_search.acceptor().make_orig_progress_check(
1083 reference_theta,
1084 reference_barr,
1085 5.0,
1086 );
1087
1088 let (Some(nlp), Some(sd), Some(resto)) = (
1089 self.nlp.as_ref(),
1090 self.search_dir.as_mut(),
1091 self.restoration.as_mut(),
1092 ) else {
1093 return IterateOutcome::Terminate(SolverReturn::RestorationFailure);
1094 };
1095 resto.set_orig_progress_check(orig_progress_cb);
1096 let mut pd_guard = sd.pd_solver_mut();
1097 let aug = pd_guard.aug_solver_mut();
1098 // Audit counters (pounce#12). Increment call count + outer-iter
1099 // count (one outer iter is consumed per restoration call) and
1100 // wall-time around the inner call. Inner iter count is read
1101 // after via the trait accessor.
1102 self.resto_calls = self.resto_calls.saturating_add(1);
1103 self.resto_outer_iters = self.resto_outer_iters.saturating_add(1);
1104 let resto_t0 = std::time::Instant::now();
1105 let outcome = resto.perform_restoration(&self.data, &self.cq, nlp, aug);
1106 drop(pd_guard);
1107 self.resto_wall_secs += resto_t0.elapsed().as_secs_f64();
1108 self.resto_inner_iters = self
1109 .resto_inner_iters
1110 .saturating_add(resto.last_inner_iter_count());
1111 match outcome {
1112 RestorationOutcome::Recovered => {
1113 // The driver has staged the recovered point on
1114 // `data.trial`; promote it and continue iterating.
1115 self.data.borrow_mut().accept_trial_point();
1116 // Snapshot the recovery iterate for the slow-cycle
1117 // detector at the top of the next `invoke_restoration`.
1118 // Compared against next-entry curr, dx_rel ≈ ‖α·d‖ —
1119 // measures purely the outer step. See header comment
1120 // on the cycle detector above.
1121 let recovered = self
1122 .data
1123 .borrow()
1124 .curr
1125 .as_ref()
1126 .expect("accept_trial_point sets curr")
1127 .clone();
1128 self.last_resto_recovery_x = Some(recovered.x.make_new_copy());
1129 self.last_resto_recovery_s = Some(recovered.s.make_new_copy());
1130 // Mirror upstream `IpoptAlgorithm::AcceptTrialPoint`
1131 // (`IpIpoptAlg.cpp:917-963`): kappa_sigma clamp on the
1132 // four bound-multiplier vectors. Upstream applies this
1133 // unconditionally inside AcceptTrialPoint, so the
1134 // post-restoration path inherits it; pounce factored
1135 // the clamp out of the data swap so we must call it
1136 // explicitly here. Without it the all-1 multiplier
1137 // reset (`bound_mult_reset_threshold`) leaves z*s far
1138 // from mu at the recovered iterate, blowing up the
1139 // next KKT solve's σ = z/s diagonal.
1140 self.correct_bound_multiplier();
1141 IterateOutcome::Continue
1142 }
1143 RestorationOutcome::Failed => {
1144 // Mirrors upstream `IpBacktrackingLineSearch.cpp:611-623`:
1145 // when `PerformRestoration` returns false, attempt to
1146 // roll back to the most recent acceptable iterate before
1147 // surfacing failure. If a snapshot is available we exit
1148 // cleanly with `StopAtAcceptablePoint` (mapped by the
1149 // application layer to `Solved_To_Acceptable_Level`),
1150 // matching the upstream `ACCEPTABLE_POINT_REACHED`
1151 // throw. Without a snapshot we surface
1152 // `RestorationFailure` — unless the restoration left the
1153 // iterate diverging (`|x|_∞ > diverging_iterates_tol`), in
1154 // which case we surface `DivergingIterates` to mirror the
1155 // outcome upstream produces on pathological problems like
1156 // MESH (where ipopt reports `Diverging_Iterates` and
1157 // pounce previously reported `Restoration_Failed` with an
1158 // obj of −3.6e+33).
1159 if self.restore_acceptable_point() {
1160 IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint)
1161 } else if let Some(curr) = self.data.borrow().curr.as_ref() {
1162 if curr.x.amax() > self.diverging_iterates_tol {
1163 IterateOutcome::Terminate(SolverReturn::DivergingIterates)
1164 } else {
1165 IterateOutcome::Terminate(SolverReturn::RestorationFailure)
1166 }
1167 } else {
1168 IterateOutcome::Terminate(SolverReturn::RestorationFailure)
1169 }
1170 }
1171 RestorationOutcome::LocallyInfeasible => {
1172 // Mirrors upstream's catch of `LOCALLY_INFEASIBLE` thrown
1173 // from `IpRestoConvCheck.cpp:240` — the resto sub-IPM
1174 // settled at a stationary point of `||c(x)||_1` whose
1175 // residual is still well above `tol`. Without this
1176 // detection the outer would re-enter restoration on the
1177 // unchanged iterate forever.
1178 IterateOutcome::Terminate(SolverReturn::LocalInfeasibility)
1179 }
1180 }
1181 }
1182
1183 /// Port of `IpIpoptAlg::correct_bound_multiplier`
1184 /// (`IpIpoptAlg.cpp:1055-1134`). Clamp each bound multiplier
1185 /// component into `[mu/(kappa_sigma * s_i), kappa_sigma * mu / s_i]`
1186 /// for all four bound-multiplier vectors.
1187 fn correct_bound_multiplier(&mut self) {
1188 if self.kappa_sigma < 1.0 {
1189 return;
1190 }
1191 let mu = self.data.borrow().curr_mu;
1192 let curr = match self.data.borrow().curr.clone() {
1193 Some(c) => c,
1194 None => return,
1195 };
1196
1197 let cq = self.cq.borrow();
1198
1199 let z_l_new = clamp_against_slack(&*curr.z_l, &*cq.curr_slack_x_l(), mu, self.kappa_sigma);
1200 let z_u_new = clamp_against_slack(&*curr.z_u, &*cq.curr_slack_x_u(), mu, self.kappa_sigma);
1201 let v_l_new = clamp_against_slack(&*curr.v_l, &*cq.curr_slack_s_l(), mu, self.kappa_sigma);
1202 let v_u_new = clamp_against_slack(&*curr.v_u, &*cq.curr_slack_s_u(), mu, self.kappa_sigma);
1203 drop(cq);
1204
1205 let new_iv = crate::iterates_vector::IteratesVector::new(
1206 curr.x.clone(),
1207 curr.s.clone(),
1208 curr.y_c.clone(),
1209 curr.y_d.clone(),
1210 z_l_new,
1211 z_u_new,
1212 v_l_new,
1213 v_u_new,
1214 );
1215 self.data.borrow_mut().set_curr(new_iv);
1216 }
1217
1218 /// Outer entry point — port of `IpoptAlgorithm::Optimize()`. Calls
1219 /// the iterate-initializer once, then loops `iterate()` until a
1220 /// terminal status. The exception → SolverReturn mapping
1221 /// (TINY_STEP_DETECTED → STEP_BECOMES_TINY,
1222 /// RESTORATION_FAILED → RESTORATION_FAILURE, etc.) lands in
1223 /// Phase 9 alongside the restoration phase.
1224 pub fn optimize(&mut self) -> SolverReturn {
1225 // Shared timing accumulator — every phase below records into it.
1226 let timing = self.data.borrow().timing.clone();
1227
1228 // Install the shared accumulator on the augmented-system solver
1229 // so its factor / back-solve calls are attributed to
1230 // `linear_system_factorization` / `linear_system_back_solve`.
1231 // Same pattern for the diagnostics state when present, so KKT
1232 // dump sites can consult per-iter gating.
1233 if let Some(sd) = self.search_dir.as_mut() {
1234 sd.pd_solver_mut()
1235 .aug_solver_mut()
1236 .set_timing_stats(std::rc::Rc::clone(&timing));
1237 if let Some(diag) = self.diagnostics.as_ref() {
1238 sd.pd_solver_mut()
1239 .aug_solver_mut()
1240 .set_diagnostics(Rc::clone(diag));
1241 }
1242 }
1243
1244 // 0a. Strategy initialization — port of upstream's
1245 // `IpoptAlgorithm::InitializeImpl` calls. The mu update needs
1246 // `data.curr_mu`/`curr_tau` seeded before the iterate
1247 // initializer runs (`CalculateSafeSlack` reads them).
1248 self.bundle.mu_update.initialize(&self.data);
1249
1250 // 0b. Iterate initializer. Requires NLP; without one the caller
1251 // must have populated `data.curr` themselves.
1252 if let Some(nlp) = self.nlp.as_ref() {
1253 // The initializer needs an aug-system solver for the
1254 // least-square multiplier branch; until that's wired we
1255 // route through whatever the search-direction calculator
1256 // owns when present. For the stub flow we skip the LSM
1257 // path by giving the initializer a dummy solver only if
1258 // the search_dir is present (otherwise the init function
1259 // is responsible for not consulting it).
1260 if let Some(sd) = self.search_dir.as_mut() {
1261 timing.initialize_iterates.start();
1262 let mut pd_guard = sd.pd_solver_mut();
1263 let aug_solver = pd_guard.aug_solver_mut();
1264 let ok = self
1265 .bundle
1266 .init
1267 .set_initial_iterates(&self.data, &self.cq, nlp, aug_solver);
1268 drop(pd_guard);
1269 timing.initialize_iterates.end();
1270 if !ok {
1271 return SolverReturn::InternalError;
1272 }
1273 }
1274 }
1275
1276 // 0c. Seed `IpoptData::w` with the initial-iterate Hessian.
1277 // Redundant with the iter-body `update_hessian` call (which
1278 // now runs BEFORE `update_barrier_parameter`) but kept to
1279 // cover any code path that consults `data.w` between
1280 // `set_initial_iterates` and the first `iterate()` call
1281 // (e.g. the iter-0 trace dump below).
1282 if self.data.borrow().curr.is_some() {
1283 timing.update_hessian.start();
1284 let _ = self.bundle.hess.update_hessian(&self.data, &self.cq);
1285 timing.update_hessian.end();
1286 }
1287
1288 // Track-A iterate-trace dumper. Activated by
1289 // `IPOPT_ITER_DUMP_PATH`; otherwise no-op. See `iter_dump.rs`.
1290 let mut dumper = IterDumper::from_env();
1291 // Iter 0 record — captures the initialised iterate before any
1292 // step. Mirrors upstream's "after InitializeIterates(), before
1293 // the loop" emission point.
1294 if let Some(d) = dumper.as_mut() {
1295 d.write_record(&self.data, &self.cq);
1296 }
1297
1298 // Advance the diagnostics iter counter so the first `iterate()`
1299 // body reports as iter 0 (matches `data.iter_count`). Subsequent
1300 // bumps live at the bottom of the loop alongside the iter_count
1301 // bookkeeping.
1302 if let Some(diag) = self.diagnostics.as_ref() {
1303 diag.bump_iter();
1304 }
1305
1306 // Iter 0 intermediate callback — upstream fires once after
1307 // `InitializeIterates` before the loop body starts so users
1308 // observe the initial point.
1309 if !self.fire_intermediate() {
1310 return SolverReturn::UserRequestedStop;
1311 }
1312
1313 loop {
1314 match self.iterate() {
1315 IterateOutcome::Terminate(ret) => return ret,
1316 IterateOutcome::Continue => {
1317 // Source the local counter from `data.iter_count`
1318 // each pass so a pre-seeded counter (e.g. the inner
1319 // restoration IPM at `outer.iter + 1`, matching
1320 // upstream `IpRestoMinC_1Nrm.cpp:181`) and any
1321 // restoration step that set
1322 // `data.iter_count = inner.iter_count - 1`
1323 // (mirroring `IpRestoMinC_1Nrm.cpp:Set_iter_count`)
1324 // are honored — without this the local counter
1325 // would advance from its pre-restoration value,
1326 // ignoring the inner-IPM iterations.
1327 let mut iter_count: Index = self.data.borrow().iter_count;
1328 iter_count += 1;
1329 if iter_count >= self.max_iter {
1330 return SolverReturn::MaxiterExceeded;
1331 }
1332 self.data.borrow_mut().iter_count = iter_count;
1333 // Keep the diagnostics counter in lock-step with
1334 // `data.iter_count` so KKT-dump gating reflects the
1335 // about-to-execute iteration.
1336 if let Some(diag) = self.diagnostics.as_ref() {
1337 diag.bump_iter();
1338 }
1339 // Per-iteration record — emitted after the
1340 // iter_count bump so the recorded `iter` field
1341 // matches `IpData().iter_count()` at the moment of
1342 // emission, identical to upstream's writer.
1343 if let Some(d) = dumper.as_mut() {
1344 d.write_record(&self.data, &self.cq);
1345 }
1346 // Per-iteration intermediate callback — fired with
1347 // an `IntermediateContext` guard so downstream
1348 // inspector entry points (the C API
1349 // `GetIpoptCurrent*` family) see live state for the
1350 // duration of the user callback.
1351 if !self.fire_intermediate() {
1352 return SolverReturn::UserRequestedStop;
1353 }
1354 }
1355 }
1356 }
1357 }
1358}
1359
1360/// Internal result of one [`IpoptAlgorithm::iterate`] call. Mirrors the
1361/// upstream try/catch around `IpoptAlg::Optimize` — anything that's not
1362/// `Continue` carries the [`SolverReturn`] that the outer loop will
1363/// surface to `IpoptApplication`.
1364enum IterateOutcome {
1365 Continue,
1366 Terminate(SolverReturn),
1367}
1368
1369/// `||a - b||_2 / (1 + ||b||_2)`. Used by the restoration cycle
1370/// detector in [`IpoptAlgorithm::invoke_restoration`] to test whether
1371/// the outer iterate has moved between two consecutive restoration
1372/// entries.
1373fn relative_distance(a: &dyn Vector, b: &dyn Vector) -> Number {
1374 if a.dim() == 0 {
1375 return 0.0;
1376 }
1377 let mut diff = a.make_new_copy();
1378 diff.axpy(-1.0, b);
1379 diff.nrm2() / (1.0 + b.nrm2())
1380}
1381
1382/// `out = curr + α_p · δ` for the primal/equality blocks and
1383/// `out = curr + α_d · δ` for the bound multipliers, returned as a
1384/// fresh frozen `IteratesVector`. Mirrors `scaled_step` in the line
1385/// search; duplicated here for the tiny-step branch which bypasses
1386/// the line-search driver.
1387fn scaled_step_unchecked(
1388 curr: &crate::iterates_vector::IteratesVector,
1389 delta: &crate::iterates_vector::IteratesVector,
1390 alpha_primal: Number,
1391 alpha_dual: Number,
1392) -> crate::iterates_vector::IteratesVector {
1393 let mut out = curr.make_new_zeroed();
1394 out.add_one_vector(1.0, curr, 0.0);
1395 out.x.axpy(alpha_primal, &*delta.x);
1396 out.s.axpy(alpha_primal, &*delta.s);
1397 out.y_c.axpy(alpha_primal, &*delta.y_c);
1398 out.y_d.axpy(alpha_primal, &*delta.y_d);
1399 out.z_l.axpy(alpha_dual, &*delta.z_l);
1400 out.z_u.axpy(alpha_dual, &*delta.z_u);
1401 out.v_l.axpy(alpha_dual, &*delta.v_l);
1402 out.v_u.axpy(alpha_dual, &*delta.v_u);
1403 out.freeze()
1404}
1405
1406/// Allocate a fresh `Rc<dyn Vector>` with `kappa_sigma_clamp`
1407/// applied component-wise against the supplied `slack`. Inputs are
1408/// borrowed; the original `z` is never mutated. Ports the per-vector
1409/// piece of `IpIpoptAlg.cpp:1080-1133`.
1410fn clamp_against_slack(
1411 z: &dyn Vector,
1412 slack: &dyn Vector,
1413 mu: Number,
1414 kappa_sigma: Number,
1415) -> Rc<dyn Vector> {
1416 debug_assert_eq!(z.dim(), slack.dim());
1417 let n = z.dim() as usize;
1418 // Flatten both z and slack into contiguous slices so the
1419 // elementwise clamp doesn't care whether the inputs are
1420 // [`DenseVector`] (regular IPM path) or [`CompoundVector`]
1421 // (resto IPM path). The result is reconstructed into a
1422 // same-shape Vector via `Vector::make_new` + a flat-write
1423 // helper so the caller sees a vector with the same blocking as
1424 // its input.
1425 let mut buf = vec![0.0_f64; n];
1426 flat_read_into(z, &mut buf);
1427 let s_vals = flat_read_owned(slack);
1428 let _ = kappa_sigma_clamp(&mut buf, &s_vals, mu, kappa_sigma);
1429 let mut out: Box<dyn Vector> = z.make_new();
1430 flat_write_into(&mut *out, &buf);
1431 Rc::from(out)
1432}
1433
1434fn flat_read_into(v: &dyn Vector, dst: &mut [Number]) {
1435 if let Some(dv) = v
1436 .as_any()
1437 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1438 {
1439 let vs = dv.expanded_values();
1440 dst.copy_from_slice(&vs);
1441 return;
1442 }
1443 if let Some(cv) = v.as_any().downcast_ref::<pounce_linalg::CompoundVector>() {
1444 let mut off = 0usize;
1445 for k in 0..cv.n_comps() {
1446 let blk = cv.comp(k);
1447 let dim = blk.dim() as usize;
1448 let dblk = blk
1449 .as_any()
1450 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1451 .expect("clamp_against_slack: CompoundVector blocks must be DenseVectors");
1452 let vs = dblk.expanded_values();
1453 dst[off..off + dim].copy_from_slice(&vs);
1454 off += dim;
1455 }
1456 return;
1457 }
1458 panic!("clamp_against_slack: unsupported Vector kind");
1459}
1460
1461fn flat_read_owned(v: &dyn Vector) -> Vec<Number> {
1462 let mut out = vec![0.0; v.dim() as usize];
1463 flat_read_into(v, &mut out);
1464 out
1465}
1466
1467fn flat_write_into(v: &mut dyn Vector, src: &[Number]) {
1468 if let Some(dv) = v
1469 .as_any_mut()
1470 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
1471 {
1472 dv.set_values(src);
1473 return;
1474 }
1475 if let Some(cv) = v
1476 .as_any_mut()
1477 .downcast_mut::<pounce_linalg::CompoundVector>()
1478 {
1479 let mut off = 0usize;
1480 for k in 0..cv.n_comps() {
1481 let blk = cv.comp_mut(k);
1482 let dim = blk.dim() as usize;
1483 let dblk = blk
1484 .as_any_mut()
1485 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
1486 .expect("clamp_against_slack: CompoundVector blocks must be DenseVectors");
1487 dblk.set_values(&src[off..off + dim]);
1488 off += dim;
1489 }
1490 return;
1491 }
1492 panic!("clamp_against_slack: unsupported Vector kind");
1493}
1494
1495/// Per-element kappa-sigma clamp — the elementwise arithmetic at the
1496/// heart of `IpIpoptAlg.cpp:correct_bound_multiplier` (lines
1497/// 1090-1133). For each index `i`:
1498///
1499/// ```text
1500/// slack_i = max(slack_i, tiny_double) // avoid /0
1501/// z_lo_i = mu / (kappa_sigma * slack_i)
1502/// z_hi_i = kappa_sigma * mu / slack_i
1503/// z_i ← clamp(z_i, z_lo_i, z_hi_i)
1504/// ```
1505///
1506/// Returns the maximum elementwise correction magnitude (matching
1507/// upstream's `Max(max_correction_up, max_correction_low)`).
1508///
1509/// `kappa_sigma < 1` short-circuits to the identity per upstream's
1510/// guard at line 1065.
1511pub fn kappa_sigma_clamp(
1512 z: &mut [Number],
1513 slack: &[Number],
1514 mu: Number,
1515 kappa_sigma: Number,
1516) -> Number {
1517 debug_assert_eq!(z.len(), slack.len());
1518 if kappa_sigma < 1.0 {
1519 return 0.0;
1520 }
1521 let mut max_correction = 0.0_f64;
1522 for (zi, &si) in z.iter_mut().zip(slack.iter()) {
1523 let s_safe = si.max(Number::MIN_POSITIVE);
1524 let lo = mu / (kappa_sigma * s_safe);
1525 let hi = kappa_sigma * mu / s_safe;
1526 let clamped = zi.clamp(lo, hi);
1527 let delta = (clamped - *zi).abs();
1528 if delta > max_correction {
1529 max_correction = delta;
1530 }
1531 *zi = clamped;
1532 }
1533 max_correction
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538 use super::*;
1539
1540 #[test]
1541 fn kappa_sigma_below_one_is_identity() {
1542 let mut z = vec![1.0, 2.0, 3.0];
1543 let slack = [1.0, 1.0, 1.0];
1544 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 0.5);
1545 assert_eq!(m, 0.0);
1546 assert_eq!(z, [1.0, 2.0, 3.0]);
1547 }
1548
1549 #[test]
1550 fn within_band_is_unchanged() {
1551 // mu=1, kappa=10, slack=1 → band [0.1, 10]. z=1 → unchanged.
1552 let mut z = vec![1.0];
1553 let slack = [1.0];
1554 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
1555 assert_eq!(m, 0.0);
1556 assert_eq!(z, [1.0]);
1557 }
1558
1559 #[test]
1560 fn above_upper_clamped_down() {
1561 // mu=1, kappa=10, slack=1 → upper = 10. z=100 → 10.
1562 let mut z = vec![100.0];
1563 let slack = [1.0];
1564 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
1565 assert!((m - 90.0).abs() < 1e-13);
1566 assert_eq!(z, [10.0]);
1567 }
1568
1569 #[test]
1570 fn below_lower_clamped_up() {
1571 // mu=1, kappa=10, slack=1 → lower = 0.1. z=0.001 → 0.1.
1572 let mut z = vec![0.001];
1573 let slack = [1.0];
1574 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
1575 assert!((m - 0.099).abs() < 1e-13);
1576 assert!((z[0] - 0.1).abs() < 1e-15);
1577 }
1578
1579 #[test]
1580 fn returns_max_over_components() {
1581 let mut z = vec![100.0, 0.001];
1582 let slack = [1.0, 1.0];
1583 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
1584 assert!((m - 90.0).abs() < 1e-13);
1585 assert_eq!(z[0], 10.0);
1586 assert!((z[1] - 0.1).abs() < 1e-15);
1587 }
1588
1589 #[test]
1590 fn slack_clamped_to_min_positive_avoids_division_by_zero() {
1591 let mut z = vec![1e100];
1592 let slack = [0.0];
1593 let _ = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
1594 assert!(z[0].is_finite() || z[0] == 1e100);
1595 }
1596
1597 /// The restoration slot is exercised structurally:
1598 /// `IpoptAlgorithm::with_restoration` accepts a
1599 /// `Box<dyn RestorationPhase>` and the trait's default
1600 /// `perform_restoration` returns `Failed`. End-to-end coverage
1601 /// (iterate() → line-search-Failed → restoration → recovered)
1602 /// lands in the Phase 9 integration suite alongside the nested
1603 /// IPM driver.
1604 struct _DummyResto;
1605 impl RestorationPhase for _DummyResto {}
1606}