Skip to main content

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}