Skip to main content

pounce_algorithm/
square_system.rs

1//! Square-system recovery: when the interior-point solve of a square,
2//! constant-objective model fails, solve `c(x) = 0` directly.
3//!
4//! # Why
5//!
6//! A model with as many free variables as equality rows, no inequality rows
7//! and a constant objective is not an optimization problem. It is a system of
8//! nonlinear equations wearing an NLP's clothes, and every feasible point is a
9//! global optimum. That is the class an equation-oriented flowsheet
10//! initialization hands a solver (IDAES-style: fix the degrees of freedom,
11//! solve the square block), and it is the class where an interior-point
12//! failure has the least to do with the model: the barrier, the filter and
13//! the Hessian approximation all exist to trade objective against
14//! feasibility, and here there is no objective to trade.
15//!
16//! Measured on `square_flowsheet_resto` (32×32, `f ≡ 0`, feasible by
17//! construction). Its Jacobian is nearly rank-deficient even at the solution
18//! (`σ_min ≈ 3.6e-9` against `σ_max ≈ 3e6`; the near-null direction is a
19//! uniform shift of the eight temperatures, pinned only by `exp(-6000/T)`),
20//! so the solution set is nearly non-isolated. Under
21//! `hessian_approximation=limited-memory` POUNCE on FERAL PR#222 runs to the
22//! 3000-iteration cap and every rung of the second-opinion ladder fails too;
23//! upstream Ipopt 3.14.19 declares local infeasibility at iteration 50. A
24//! Levenberg–Marquardt least-squares solve that uses only `J` solves it from
25//! the same start in 8 iterations. The two figures are from two FERALs and
26//! both are named: on FERAL PR#222 at default options, `max|c| = 1.9e-10`
27//! (recorded when the stage was written); on the published FERAL 0.20 this
28//! crate builds against, where rung 4 (`feral_increase_quality=no`) rescues
29//! the leg first by default and the stage is reached only with
30//! `feral_increase_quality_retry=no`, `max|c| = 2.874e-10` (scaled
31//! `1.710e-10`), at `max_iter` 500 and 3000 alike.
32//!
33//! # The gate
34//!
35//! [`probe`] opens this stage only when all of the following hold of the TNLP
36//! the ladder was handed:
37//!
38//! * every row is an equality (`g_l == g_u`, finite) and there is at least
39//!   one;
40//! * the number of **free** variables — `x_l < x_u`, i.e. after removing the
41//!   variables `fixed_variable_treatment` would remove — equals the row count;
42//! * the objective is constant: the model does not *prove* `∇f` varies
43//!   ([`TNLP::derivative_proofs`]), and `∇f` evaluates to exactly zero, every
44//!   component, at the starting point.
45//!
46//! The last test is evaluated, not proved, and that is a deliberate choice.
47//! A structural proof would be the stronger gate, but it is not available on
48//! the path that matters: presolve wrappers decline `derivative_proofs` (they
49//! change what the rows are), and the C, Python and CasADi surfaces hand
50//! POUNCE callbacks, not algebra. What makes an evaluated gate safe is that
51//! **nothing reported rests on it**. The gate only decides whether to spend
52//! the attempt. The verdict rests on the certificate checked at the returned
53//! point `x̂` itself (below), which includes `∇f(x̂) = 0` evaluated *there*:
54//! at a feasible `x̂` with `∇f(x̂) = 0`, `(λ, z) = (0, 0)` satisfies
55//! stationarity and complementarity exactly, so `x̂` is a first-order KKT
56//! point by the same definition the IPM's convergence test uses — whether or
57//! not `f` is constant anywhere else.
58//!
59//! # The algorithm
60//!
61//! A bounded, scaled trust-region Levenberg–Marquardt method on
62//! `min ½‖c(x)‖²` over the free variables:
63//!
64//! * **Step.** Moré's trust-region form (Moré 1978, MINPACK `lmder`/`lmpar`):
65//!   with column scaling `D`, the step `p̃ = D p` solves
66//!   `(ÃᵀÃ + λI) p̃ = −Ãᵀc`, `Ã = J D⁻¹`, with `λ ≥ 0` chosen so
67//!   `‖p̃‖ ≈ Δ` (within 10%) by safeguarded Newton iteration on the secular
68//!   equation `1/‖p̃(λ)‖ = 1/Δ`. The Gauss–Newton step is taken when it
69//!   already fits.
70//! * **Linear algebra.** Each `λ` trial factors the quasi-definite augmented
71//!   system `[[λI, Ãᵀ], [Ã, −I]]` with the workspace's sparse symmetric
72//!   backend (FERAL) rather than forming `JᵀJ`, which squares the condition
73//!   number and densifies. One extra back-solve against the same factor gives
74//!   `(ÃᵀÃ + λI)⁻¹ p̃` for the Newton update. At most
75//!   [`MAX_LAMBDA_TRIALS`] factorizations per iteration.
76//! * **Scaling.** `D_j = max over iterates of ‖J_{:,j}‖₂` (Moré's
77//!   non-decreasing column norms), which makes the method invariant to
78//!   rescaling a variable — the fixture's variables span `1e-6` to `9e6`.
79//!   The rows are **not** reweighted: equilibrating them by `‖J_i(x₀)‖∞` was
80//!   measured on the fixture and lost it from every start tried, while the
81//!   unweighted merit (MINPACK's, and scipy's `method='lm'`) solves it.
82//! * **Bounds.** The trial point is the projection of `x + p` onto the box,
83//!   and the gain ratio is computed for the *projected* step against the
84//!   linear model, so a projection that destroys the predicted decrease
85//!   shrinks `Δ` — which drives `λ` up and the step toward the scaled
86//!   (projected) gradient. Every iterate is inside the box by construction.
87//!   This is the projected Levenberg–Marquardt idea of Kanzow, Yamashita &
88//!   Fukushima (2004), in a trust-region rather than a line-search
89//!   globalization.
90//! * **Update.** Gain ratio `ρ = ared/pred`; accept when `ρ ≥ 1e-4`; halve
91//!   `Δ` (to at most the step taken) when `ρ < 0.25`; set `Δ = 2‖D s‖` when
92//!   `ρ > 0.75` or the step was Gauss–Newton. A non-finite residual at the
93//!   trial point is a rejection with `Δ` quartered.
94//!
95//! # Termination and honesty
96//!
97//! The LM stops as soon as the iterate passes the acceptance test, or fails
98//! on an iteration cap (`min(max_iter, 200)`), on a vanishing projected
99//! gradient of `‖c‖²` at a non-root ([`STATIONARITY_TOL`]), on `Δ` collapsing to
100//! `1e-15·max(‖Dx‖, 1)` (a projected stationary point of `‖c‖²` that is not a
101//! root — a *local* least-squares minimum, so the original verdict stands),
102//! or on an evaluation / factorization failure.
103//!
104//! The acceptance test is the IPM's own, at zero multipliers: Ipopt's
105//! convergence test asks for the *scaled* overall error below `tol` and the
106//! *unscaled* constraint violation below `constr_viol_tol`, and at
107//! `(λ, z) = (0, 0)` with `∇f = 0` the dual and complementarity terms vanish,
108//! leaving `max_i s_i|c_i| ≤ tol` and `max_i |c_i| ≤ constr_viol_tol`. `s_i`
109//! is the row scaling the NLP arm computes for the same model, by the same
110//! `pounce_nlp::orig_ipopt_nlp` helpers at the same point (`ipm_scaling`
111//! has the details): `none` is `1`; `user-scaling` and `curvature-based`
112//! are the TNLP's `g_scaling` floored at `nlp_scaling_min_value`;
113//! `gradient-based` — and `equilibration-based`, which the NLP arm runs as
114//! gradient-based — is the row-max rule over the full Jacobian sampled at
115//! the TNLP's starting point with only fixed variables lifted, applied only
116//! when some row exceeds `nlp_scaling_max_gradient` (or a constraint target
117//! gradient is set). The one thing not reproduced is a wrapper the
118//! application inserts *inside* `optimize_tnlp` that moves the start; that
119//! can change `s_i`, but the unscaled half of the test is independent of
120//! `s_i`, so no choice of `s_i` admits a point with
121//! `max|c_i| > constr_viol_tol`. On top of that
122//! the box is satisfied **exactly** (projection; no `bound_relax_factor`
123//! widening is borrowed), and `∇f(x̂)` must be exactly zero at the returned
124//! point. If any check fails the stage reports failure and the caller keeps
125//! the original verdict untouched.
126//!
127//! # Budget and callback
128//!
129//! The LM honours `max_wall_time` and `max_cpu_time`, as a budget measured
130//! from the start of the stage — the per-solve budget each option rung's
131//! `optimize_tnlp` also gets — and reads the clock once per iterate, after
132//! the convergence test. It fires the TNLP's `intermediate_callback` at
133//! every iterate, the start included, with `mode = RegularMode`, `inf_pr`
134//! the scaled violation, `inf_du = max|∇f|`, `mu = 0`, `d_norm` the
135//! accepted step's `‖s‖∞` and `regularization_size` the LM parameter; a
136//! `false` stops it. A stop or an exhausted budget ends the stage without a
137//! promotion. No live iterate is installed for the callback
138//! (`GetIpoptCurrentIterate` finds none), because there is no IPM iterate.
139//!
140//! # What is reported
141//!
142//! `Solve_Succeeded` with `x̂`, `λ = 0` and `z_l = z_u = 0`, and statistics
143//! that describe the LM: `iteration_count` is its iteration count and, when
144//! the application records iteration history, `iterations` holds one record
145//! per iterate (the start included, the IPM's convention; `alpha_primal_char`
146//! `L`); `final_scaled_objective` is `f` times the objective multiplier the
147//! NLP arm would have applied. The post-solve computations that read the
148//! IPM's converged KKT factor (sIPOPT, the reduced Hessian) have nothing to
149//! read and are not run; the CLI says so. Those are true
150//! KKT multipliers, not placeholders: with `∇f(x̂) = 0` they satisfy
151//! `∇f + Jᵀλ − z_l + z_u = 0` exactly. They are unique when `J(x̂)` is
152//! nonsingular and no bound is active; when `J` is singular (as on the
153//! fixture, numerically) the multiplier set is a cone containing zero and
154//! zero is its minimum-norm member. They are **not** the multipliers an IPM
155//! would have reported for the same point, because the IPM never reached it.
156//!
157//! The root may differ from the one an IPM would have reached: a square
158//! system can have several, and the fixture's nearly non-isolated solution
159//! set puts the LM root about 11% (relative, in the temperature block) from
160//! the generator's `x*`. With a constant objective each is a global optimum.
161
162use pounce_common::options_list::OptionsList;
163use pounce_common::types::{Index, Number};
164use pounce_linsol::{ESymSolverStatus, SparseSymLinearSolverInterface};
165use pounce_nlp::SolveStatistics;
166use pounce_nlp::alg_types::SolverReturn;
167use pounce_nlp::constant_derivatives::DerivativeProof;
168use pounce_nlp::tnlp::{
169    BoundsInfo, IndexStyle, IpoptCq, IpoptData, ScalingRequest, Solution, SparsityRequest,
170    StartingPoint, TNLP,
171};
172use std::cell::RefCell;
173use std::rc::Rc;
174
175/// The label this stage carries in the second-opinion record (`tried`,
176/// `promoted_by`, the sweep's `2nd=` column).
177pub const SQUARE_SYSTEM_RUNG_LABEL: &str = "square_system_lm";
178
179/// Factorizations allowed per LM iteration while solving the secular
180/// equation for `λ` (MINPACK `lmpar` allows 10 Newton steps; the extra one
181/// is the Gauss–Newton trial).
182pub const MAX_LAMBDA_TRIALS: usize = 11;
183
184/// The iteration cap is `min(max_iter, this)`. Measured on the fixture from
185/// eight starts (`x*` scaled by 0.1 … 10 per block): 5 to 143 iterations.
186pub const LM_ITERATION_CAP: usize = 200;
187
188/// Smallest `λ` the augmented system is factored at. `λ = 0` makes the
189/// Schur complement `ÃᵀÃ` exactly singular on a rank-deficient Jacobian,
190/// and `Ã` is column-normalized, so `1e-16` is an `ε`-sized regularization.
191const LAMBDA_FLOOR: Number = 1e-16;
192
193/// MINPACK's `gtol` test: stop when every column of `Ã` is orthogonal to
194/// `c` to within this cosine (the columns are normalized by `D`, so each
195/// `|Ã_jᵀc|/‖c‖` lies in `[0, 1]`), counting only directions the box lets
196/// the iterate move in. Set at the noise level on purpose: it exists for the
197/// exact case — `degenerate_start_infeasible` starts where `J = 0`, and
198/// without it the method spends 57 iterations halving `Δ` at a point no
199/// step can leave — and must never fire on the near-null directions of a
200/// nearly singular but consistent system like `square_flowsheet_resto`.
201pub const STATIONARITY_TOL: Number = 1e-12;
202
203/// A residual system `c: R^n → R^m` with a fixed sparse Jacobian pattern.
204pub trait ResidualSystem {
205    /// `c(x)` into `c`. `false` when the model cannot be evaluated at `x`.
206    fn eval_c(&mut self, x: &[Number], c: &mut [Number]) -> bool;
207    /// Jacobian values at `x`, in the order of [`LmProblem::jac_rows`].
208    fn eval_jac(&mut self, x: &[Number], vals: &mut [Number]) -> bool;
209}
210
211/// Everything the LM core needs that is not a callback.
212#[derive(Debug, Clone)]
213pub struct LmProblem {
214    pub n: usize,
215    pub m: usize,
216    /// Jacobian triplet pattern, 0-based. Duplicates are summed.
217    pub jac_rows: Vec<usize>,
218    pub jac_cols: Vec<usize>,
219    /// Box; `±∞` for an absent bound.
220    pub lb: Vec<Number>,
221    pub ub: Vec<Number>,
222}
223
224/// The acceptance test, in the NLP arm's terms — see the module docs.
225#[derive(Debug, Clone)]
226pub struct FeasibilityTest {
227    /// Per-row scaling `s_i` the scaled test multiplies by.
228    pub row_scale: Vec<Number>,
229    /// `tol`, against `max_i s_i |c_i|`.
230    pub tol: Number,
231    /// `constr_viol_tol`, against `max_i |c_i|`.
232    pub constr_viol_tol: Number,
233}
234
235impl FeasibilityTest {
236    /// `(scaled, unscaled)` max-norm violations.
237    pub fn violations(&self, c: &[Number]) -> (Number, Number) {
238        let mut scaled: Number = 0.0;
239        let mut unscaled: Number = 0.0;
240        for (i, &ci) in c.iter().enumerate() {
241            let a = ci.abs();
242            // A NaN must not read as feasible: `max` would drop it.
243            if !a.is_finite() {
244                return (Number::INFINITY, Number::INFINITY);
245            }
246            unscaled = unscaled.max(a);
247            scaled = scaled.max(self.row_scale.get(i).copied().unwrap_or(1.0) * a);
248        }
249        (scaled, unscaled)
250    }
251
252    pub fn passes(&self, c: &[Number]) -> bool {
253        let (s, u) = self.violations(c);
254        s <= self.tol && u <= self.constr_viol_tol
255    }
256}
257
258/// How an LM run ended.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum LmStatus {
261    /// The iterate passed the [`FeasibilityTest`].
262    Converged,
263    /// Iteration cap reached first.
264    IterationLimit,
265    /// The projected gradient of `½‖c‖²` vanished at a point that is not a
266    /// root: a stationary point of the least-squares problem on the box
267    /// (MINPACK's `gtol` test, at [`STATIONARITY_TOL`]). No step this method
268    /// can take leaves it, so it stops rather than collapse `Δ` to get there.
269    Stationary,
270    /// `Δ` collapsed: a projected stationary point of `‖c‖²` that is not a
271    /// root, as far as this method can tell.
272    TrustRegionCollapsed,
273    /// The residual or Jacobian could not be evaluated at an accepted point,
274    /// or was not finite there.
275    EvaluationFailed,
276    /// The augmented system could not be factored at any `λ` tried.
277    LinearSolverFailed,
278    /// `max_cpu_time` ran out before the iterate passed the test.
279    CpuTimeExceeded,
280    /// `max_wall_time` ran out before the iterate passed the test.
281    WallTimeExceeded,
282    /// The TNLP's `intermediate_callback` returned `false`.
283    UserRequestedStop,
284}
285
286/// What an LM run returns.
287#[derive(Debug, Clone)]
288pub struct LmResult {
289    pub status: LmStatus,
290    /// Last accepted iterate (inside the box).
291    pub x: Vec<Number>,
292    /// `c(x)` at that iterate.
293    pub c: Vec<Number>,
294    pub iterations: usize,
295    pub residual_evals: usize,
296    pub jacobian_evals: usize,
297    pub factorizations: usize,
298}
299
300/// Merged Jacobian pattern plus the augmented-system triplets built on it.
301struct Augmented {
302    /// `slot[k]` = merged entry of TNLP triplet `k`.
303    slot: Vec<usize>,
304    /// Merged `(row, col)` pairs.
305    uniq: Vec<(usize, usize)>,
306    /// 1-based lower-triangle triplets of `[[λI, Ãᵀ], [Ã, −I]]`, laid out
307    /// `n` diagonal, then the merged `Ã` entries, then `m` diagonal.
308    irn: Vec<Index>,
309    jcn: Vec<Index>,
310}
311
312impl Augmented {
313    fn new(p: &LmProblem) -> Self {
314        let mut index = std::collections::HashMap::with_capacity(p.jac_rows.len());
315        let mut uniq = Vec::new();
316        let mut slot = Vec::with_capacity(p.jac_rows.len());
317        for (&i, &j) in p.jac_rows.iter().zip(&p.jac_cols) {
318            let next = uniq.len();
319            let s = *index.entry((i, j)).or_insert(next);
320            if s == next {
321                uniq.push((i, j));
322            }
323            slot.push(s);
324        }
325        let nnz = p.n + uniq.len() + p.m;
326        let mut irn = Vec::with_capacity(nnz);
327        let mut jcn = Vec::with_capacity(nnz);
328        for j in 0..p.n {
329            irn.push((j + 1) as Index);
330            jcn.push((j + 1) as Index);
331        }
332        for &(i, j) in &uniq {
333            irn.push((p.n + i + 1) as Index);
334            jcn.push((j + 1) as Index);
335        }
336        for i in 0..p.m {
337            irn.push((p.n + i + 1) as Index);
338            jcn.push((p.n + i + 1) as Index);
339        }
340        Self {
341            slot,
342            uniq,
343            irn,
344            jcn,
345        }
346    }
347
348    fn merge(&self, raw: &[Number], out: &mut [Number]) {
349        out.iter_mut().for_each(|v| *v = 0.0);
350        for (k, &v) in raw.iter().enumerate() {
351            out[self.slot[k]] += v;
352        }
353    }
354}
355
356fn norm2(v: &[Number]) -> Number {
357    v.iter().map(|a| a * a).sum::<Number>().sqrt()
358}
359
360fn project(x: &mut [Number], lb: &[Number], ub: &[Number]) {
361    for j in 0..x.len() {
362        if x[j] < lb[j] {
363            x[j] = lb[j];
364        }
365        if x[j] > ub[j] {
366            x[j] = ub[j];
367        }
368    }
369}
370
371/// The factored-system half of the LM: owns the backend and the scaled
372/// Jacobian values for the current iterate.
373struct StepSolver {
374    backend: Box<dyn SparseSymLinearSolverInterface>,
375    aug: Augmented,
376    n: usize,
377    m: usize,
378    /// Merged `Ã = J D⁻¹` values, aligned with `aug.uniq`.
379    a: Vec<Number>,
380    factorizations: usize,
381}
382
383impl StepSolver {
384    /// Factor `[[λI, Ãᵀ], [Ã, −I]]` and return `(p̃, (ÃᵀÃ+λI)⁻¹p̃)` for the
385    /// right-hand side `[0; −c]`. `None` on a failed or non-finite solve.
386    fn solve(&mut self, lambda: Number, c: &[Number]) -> Option<(Vec<Number>, Vec<Number>)> {
387        let (n, m) = (self.n, self.m);
388        {
389            let vals = self.backend.values_array_mut();
390            for v in vals.iter_mut().take(n) {
391                *v = lambda;
392            }
393            vals[n..n + self.a.len()].copy_from_slice(&self.a);
394            for v in vals.iter_mut().skip(n + self.a.len()) {
395                *v = -1.0;
396            }
397        }
398        let mut rhs = vec![0.0; n + m];
399        for i in 0..m {
400            rhs[n + i] = -c[i];
401        }
402        self.factorizations += 1;
403        let st =
404            self.backend
405                .multi_solve(true, &self.aug.irn, &self.aug.jcn, 1, &mut rhs, false, 0);
406        if st != ESymSolverStatus::Success || rhs.iter().any(|v| !v.is_finite()) {
407            return None;
408        }
409        let p: Vec<Number> = rhs[..n].to_vec();
410        let mut rhs2 = vec![0.0; n + m];
411        rhs2[..n].copy_from_slice(&p);
412        let st =
413            self.backend
414                .multi_solve(false, &self.aug.irn, &self.aug.jcn, 1, &mut rhs2, false, 0);
415        if st != ESymSolverStatus::Success || rhs2.iter().any(|v| !v.is_finite()) {
416            return None;
417        }
418        Some((p, rhs2[..n].to_vec()))
419    }
420
421    /// `‖Ãᵀc‖₂`, the scaled gradient norm — MINPACK's upper bound on the
422    /// `λ` that fits the trust region is `‖Ãᵀc‖/Δ`.
423    fn scaled_gradient_norm(&self, c: &[Number]) -> Number {
424        let mut g = vec![0.0; self.n];
425        for (k, &(i, j)) in self.aug.uniq.iter().enumerate() {
426            g[j] += self.a[k] * c[i];
427        }
428        norm2(&g)
429    }
430
431    /// Is `x` a stationary point of `½‖c‖²` on the box? See
432    /// [`STATIONARITY_TOL`].
433    fn is_stationary(&self, c: &[Number], x: &[Number], lb: &[Number], ub: &[Number]) -> bool {
434        let cn = norm2(c);
435        // A root (or a NaN norm) is not a stationary non-root.
436        if cn.is_nan() || cn <= 0.0 {
437            return false;
438        }
439        let mut g = vec![0.0; self.n];
440        for (k, &(i, j)) in self.aug.uniq.iter().enumerate() {
441            g[j] += self.a[k] * c[i];
442        }
443        g.iter().enumerate().all(|(j, &gj)| {
444            // Descent moves along −g: blocked at a lower bound when g > 0,
445            // at an upper bound when g < 0.
446            let blocked = (gj > 0.0 && x[j] <= lb[j]) || (gj < 0.0 && x[j] >= ub[j]);
447            blocked || gj.abs() <= STATIONARITY_TOL * cn
448        })
449    }
450
451    /// Moré's trust-region step: `λ` such that `‖p̃(λ)‖ ≈ Δ`.
452    /// Returns `(p̃, λ)`, with `λ = 0` reporting a Gauss–Newton step.
453    fn trust_region_step(
454        &mut self,
455        c: &[Number],
456        delta: Number,
457        lambda_prev: Number,
458    ) -> Option<(Vec<Number>, Number)> {
459        // Gauss–Newton first: if it fits, it is the step.
460        let mut lo: Number = 0.0;
461        if let Some((p, _)) = self.solve(LAMBDA_FLOOR, c)
462            && norm2(&p) <= 1.1 * delta
463        {
464            return Some((p, 0.0));
465        }
466        let gnorm = self.scaled_gradient_norm(c);
467        if !(gnorm.is_finite() && gnorm > 0.0) {
468            return None;
469        }
470        let mut hi = gnorm / delta;
471        let mut lambda = if lambda_prev > LAMBDA_FLOOR {
472            lambda_prev.clamp(LAMBDA_FLOOR, hi)
473        } else {
474            (1e-3 * hi).max(LAMBDA_FLOOR)
475        };
476        let mut best: Option<(Vec<Number>, Number)> = None;
477        for _ in 0..MAX_LAMBDA_TRIALS - 1 {
478            let Some((p, u)) = self.solve(lambda, c) else {
479                // A failed factorization at this λ: more regularization is
480                // the only direction that can help.
481                lo = lambda;
482                lambda = (10.0 * lambda).max(1e-12);
483                continue;
484            };
485            let pn = norm2(&p);
486            let fits = (pn - delta).abs() <= 0.1 * delta;
487            // Keep the last inside-the-region candidate as a fallback; any
488            // `p̃` with `‖p̃‖ ≤ 1.1Δ` is an acceptable (if conservative) step.
489            if pn <= 1.1 * delta {
490                best = Some((p.clone(), lambda));
491            }
492            if fits {
493                return Some((p, lambda));
494            }
495            if pn > delta {
496                lo = lo.max(lambda);
497            } else {
498                hi = hi.min(lambda);
499            }
500            let pu: Number = p.iter().zip(&u).map(|(a, b)| a * b).sum();
501            let newton = if pu > 0.0 {
502                lambda + (pn * pn / pu) * ((pn - delta) / delta)
503            } else {
504                Number::NAN
505            };
506            lambda = if newton.is_finite() && newton > lo && newton < hi {
507                newton
508            } else {
509                // Outside the bracket: MINPACK's safeguard.
510                (lo * hi).sqrt().max(1e-3 * hi).max(LAMBDA_FLOOR)
511            };
512        }
513        if best.is_none() {
514            // Out of trials without a fitting step. At `hi` the step is
515            // inside the region by construction; take it.
516            if let Some((p, _)) = self.solve(hi, c) {
517                best = Some((p, hi));
518            }
519        }
520        best
521    }
522}
523
524/// One iterate of the LM, as handed to [`LmHooks::on_iterate`].
525#[derive(Debug)]
526pub struct LmIterate<'a> {
527    /// Iteration index: `0` is the (projected) start, `k` the iterate after
528    /// the `k`-th step — accepted or not; a rejected step leaves `x` as it
529    /// was and reports `accepted = false`.
530    pub iter: usize,
531    pub x: &'a [Number],
532    pub c: &'a [Number],
533    /// `‖s‖∞` of the step that produced this iterate (`0` at the start and
534    /// after a rejected step).
535    pub step_inf_norm: Number,
536    /// The Levenberg–Marquardt parameter of that step (`0` for a
537    /// Gauss–Newton step and at the start).
538    pub lambda: Number,
539    /// Whether that step was accepted (`false` at the start).
540    pub accepted: bool,
541}
542
543/// What the LM consults besides the model: a time budget and a per-iterate
544/// observer. [`LmHooks::default`] is neither.
545#[derive(Default)]
546pub struct LmHooks<'a> {
547    /// Checked once per iterate, after the convergence test and before the
548    /// next step, so an iterate that already passes is never thrown away
549    /// for being late — the same order as the IPM's convergence check.
550    pub deadline: Option<pounce_common::timing::Deadline>,
551    /// Called at every iterate, the start included, *before* the
552    /// convergence test (the IPM's order: upstream's
553    /// `OptimalityErrorConvergenceCheck` fires the intermediate callback
554    /// first). Returning `false` stops the method with
555    /// [`LmStatus::UserRequestedStop`].
556    pub on_iterate: Option<&'a mut dyn FnMut(&LmIterate<'_>) -> bool>,
557}
558
559impl LmHooks<'_> {
560    fn fire(&mut self, it: &LmIterate<'_>) -> bool {
561        match self.on_iterate.as_mut() {
562            Some(f) => f(it),
563            None => true,
564        }
565    }
566
567    fn time_exceeded(&self) -> Option<LmStatus> {
568        use pounce_common::timing::DeadlineKind;
569        match self.deadline.as_ref().and_then(|d| d.exceeded()) {
570            Some(DeadlineKind::Cpu) => Some(LmStatus::CpuTimeExceeded),
571            Some(DeadlineKind::Wall) => Some(LmStatus::WallTimeExceeded),
572            None => None,
573        }
574    }
575}
576
577/// Solve `c(x) = 0` over the box by bounded trust-region LM. See the module
578/// docs for the method, the scaling and the termination rules.
579pub fn bounded_levenberg_marquardt(
580    model: &mut dyn ResidualSystem,
581    prob: &LmProblem,
582    x0: &[Number],
583    test: &FeasibilityTest,
584    max_iter: usize,
585    mut backend: Box<dyn SparseSymLinearSolverInterface>,
586    mut hooks: LmHooks<'_>,
587) -> LmResult {
588    let (n, m) = (prob.n, prob.m);
589    let mut x = x0.to_vec();
590    project(&mut x, &prob.lb, &prob.ub);
591    let mut c = vec![0.0; m];
592    let mut out = LmResult {
593        status: LmStatus::EvaluationFailed,
594        x: x.clone(),
595        c: c.clone(),
596        iterations: 0,
597        residual_evals: 0,
598        jacobian_evals: 0,
599        factorizations: 0,
600    };
601    out.residual_evals += 1;
602    if !model.eval_c(&x, &mut c) || c.iter().any(|v| !v.is_finite()) {
603        return out;
604    }
605    out.c = c.clone();
606    // Iterate 0, in the order every later iterate is judged in below:
607    // observer, convergence, clock.
608    let start = LmIterate {
609        iter: 0,
610        x: &x,
611        c: &c,
612        step_inf_norm: 0.0,
613        lambda: 0.0,
614        accepted: false,
615    };
616    if !hooks.fire(&start) {
617        out.status = LmStatus::UserRequestedStop;
618        return out;
619    }
620    if test.passes(&c) {
621        out.status = LmStatus::Converged;
622        return out;
623    }
624    if let Some(st) = hooks.time_exceeded() {
625        out.status = st;
626        return out;
627    }
628
629    let aug = Augmented::new(prob);
630    let nnz = aug.irn.len();
631    if backend.initialize_structure((n + m) as Index, nnz as Index, &aug.irn, &aug.jcn)
632        != ESymSolverStatus::Success
633    {
634        out.status = LmStatus::LinearSolverFailed;
635        return out;
636    }
637    let n_uniq = aug.uniq.len();
638    let mut solver = StepSolver {
639        backend,
640        aug,
641        n,
642        m,
643        a: vec![0.0; n_uniq],
644        factorizations: 0,
645    };
646
647    let mut raw = vec![0.0; prob.jac_rows.len()];
648    let mut jac = vec![0.0; n_uniq];
649    let mut d = vec![0.0; n];
650    // Evaluate J at `x`, merge duplicates, raise the column norms, and
651    // refresh `Ã`. `false` on a failed or non-finite Jacobian.
652    let refresh = |model: &mut dyn ResidualSystem,
653                   x: &[Number],
654                   raw: &mut [Number],
655                   jac: &mut [Number],
656                   d: &mut [Number],
657                   solver: &mut StepSolver|
658     -> bool {
659        if !model.eval_jac(x, raw) || raw.iter().any(|v| !v.is_finite()) {
660            return false;
661        }
662        solver.aug.merge(raw, jac);
663        let mut col = vec![0.0; d.len()];
664        for (k, &(_, j)) in solver.aug.uniq.iter().enumerate() {
665            col[j] += jac[k] * jac[k];
666        }
667        for j in 0..d.len() {
668            d[j] = d[j].max(col[j].sqrt());
669        }
670        for (k, &(_, j)) in solver.aug.uniq.iter().enumerate() {
671            let dj = if d[j] > 0.0 { d[j] } else { 1.0 };
672            solver.a[k] = jac[k] / dj;
673        }
674        true
675    };
676    out.jacobian_evals += 1;
677    if !refresh(model, &x, &mut raw, &mut jac, &mut d, &mut solver) {
678        out.factorizations = solver.factorizations;
679        return out;
680    }
681    let dscale = |d: &[Number], j: usize| if d[j] > 0.0 { d[j] } else { 1.0 };
682    let scaled_norm = |d: &[Number], v: &[Number]| -> Number {
683        v.iter()
684            .enumerate()
685            .map(|(j, &vj)| (dscale(d, j) * vj).powi(2))
686            .sum::<Number>()
687            .sqrt()
688    };
689    // MINPACK's `factor = 100` initial radius.
690    let xnorm = scaled_norm(&d, &x);
691    let mut delta = if xnorm > 0.0 { 100.0 * xnorm } else { 100.0 };
692    let mut lambda: Number = 0.0;
693    let mut ct = vec![0.0; m];
694    let status = loop {
695        // `x` is iterate `out.iterations`, and iterate 0 was judged above.
696        let iter = out.iterations;
697        if iter >= max_iter {
698            break LmStatus::IterationLimit;
699        }
700        if solver.is_stationary(&c, &x, &prob.lb, &prob.ub) {
701            break LmStatus::Stationary;
702        }
703        out.iterations = iter + 1;
704        let Some((pt, lam)) = solver.trust_region_step(&c, delta, lambda) else {
705            break LmStatus::LinearSolverFailed;
706        };
707        lambda = lam;
708        let mut xt: Vec<Number> = (0..n).map(|j| x[j] + pt[j] / dscale(&d, j)).collect();
709        project(&mut xt, &prob.lb, &prob.ub);
710        let s: Vec<Number> = (0..n).map(|j| xt[j] - x[j]).collect();
711        let snorm = scaled_norm(&d, &s);
712
713        out.residual_evals += 1;
714        let ok = model.eval_c(&xt, &mut ct) && ct.iter().all(|v| v.is_finite());
715        let rho = if ok {
716            let phi: Number = c.iter().map(|v| v * v).sum();
717            let phit: Number = ct.iter().map(|v| v * v).sum();
718            // Linear model at the *projected* step.
719            let mut lin = c.clone();
720            for (k, &(i, j)) in solver.aug.uniq.iter().enumerate() {
721                lin[i] += jac[k] * s[j];
722            }
723            let pred = phi - lin.iter().map(|v| v * v).sum::<Number>();
724            if pred > 0.0 {
725                (phi - phit) / pred
726            } else {
727                Number::NEG_INFINITY
728            }
729        } else {
730            Number::NEG_INFINITY
731        };
732
733        if !ok {
734            delta *= 0.25;
735        } else if rho < 0.25 {
736            delta = 0.5 * if snorm > 0.0 { delta.min(snorm) } else { delta };
737        } else if rho > 0.75 || lambda == 0.0 {
738            delta = delta.max(2.0 * snorm);
739        }
740
741        let accepted = rho >= 1e-4;
742        if accepted {
743            x = xt;
744            c.copy_from_slice(&ct);
745        }
746        let here = LmIterate {
747            iter: out.iterations,
748            x: &x,
749            c: &c,
750            step_inf_norm: if accepted {
751                s.iter().fold(0.0, |a: Number, v| a.max(v.abs()))
752            } else {
753                0.0
754            },
755            lambda,
756            accepted,
757        };
758        if !hooks.fire(&here) {
759            break LmStatus::UserRequestedStop;
760        }
761        if accepted && test.passes(&c) {
762            break LmStatus::Converged;
763        }
764        if let Some(st) = hooks.time_exceeded() {
765            break st;
766        }
767        if accepted {
768            out.jacobian_evals += 1;
769            if !refresh(model, &x, &mut raw, &mut jac, &mut d, &mut solver) {
770                break LmStatus::EvaluationFailed;
771            }
772        }
773        let floor = 1e-15 * scaled_norm(&d, &x).max(1.0);
774        // Written to catch a NaN radius too.
775        if delta.is_nan() || delta <= floor {
776            break LmStatus::TrustRegionCollapsed;
777        }
778    };
779    out.status = status;
780    out.x = x;
781    out.c = c;
782    out.factorizations = solver.factorizations;
783    out
784}
785
786/// Why [`probe`] did not open the stage.
787#[derive(Debug, Clone, PartialEq)]
788pub enum SquareSystemDecline {
789    /// `square_system_recovery=no`.
790    Disabled,
791    /// The TNLP could not be queried (info, bounds, start, gradient).
792    Unavailable,
793    /// No rows, or a row that is not an equality.
794    NotEqualityConstrained,
795    /// Free variables and equality rows differ in number.
796    NotSquare { free: usize, rows: usize },
797    /// `∇f` is proved varying or is nonzero at the start.
798    ObjectiveNotConstant,
799}
800
801/// A square, equality-only, constant-objective model, read off the TNLP and
802/// ready to hand to [`run`].
803#[derive(Debug, Clone)]
804pub struct SquareSystemPlan {
805    n_full: usize,
806    m: usize,
807    /// Full-space index of each free variable.
808    free: Vec<usize>,
809    /// Full-space starting point, projected into the box; fixed variables
810    /// hold their fixed value.
811    x_full: Vec<Number>,
812    /// The TNLP's starting point with only the fixed variables lifted to
813    /// their value and **no** projection — the point
814    /// `OrigIpoptNlp::determine_scaling_from_starting_point` samples the
815    /// gradient-based row scaling at, so the acceptance test's `s_i` is the
816    /// IPM's own rather than one measured somewhere nearby.
817    x_scaling: Vec<Number>,
818    /// Full Jacobian row of every TNLP triplet, fixed columns included
819    /// (the IPM's row maximum runs over all of them).
820    jac_row_full: Vec<usize>,
821    g_rhs: Vec<Number>,
822    /// `(TNLP triplet index, row, free column)` for every Jacobian entry in
823    /// a free column.
824    kept: Vec<(usize, usize, usize)>,
825    nnz_full: usize,
826    lb: Vec<Number>,
827    ub: Vec<Number>,
828}
829
830/// A numeric option as the IPM reads it: the value when set, else the
831/// registered default spelled out here — the `f.then_some(v)` idiom of
832/// `IpoptApplication`, so an options list without a registry (or one whose
833/// tag reads as an unset `0`) cannot hand the stage a different number than
834/// the interior-point method got.
835fn opt_num(options: &OptionsList, tag: &str, default: Number) -> Number {
836    options
837        .get_numeric_value(tag, "")
838        .ok()
839        .and_then(|(v, found)| found.then_some(v))
840        .unwrap_or(default)
841}
842
843/// Is the stage switched on? Read as a literal tag so the option-wiring
844/// scans see it.
845pub fn is_enabled(options: &OptionsList) -> bool {
846    options
847        .get_bool_value("square_system_recovery", "")
848        .map(|(v, _found)| v)
849        .unwrap_or(true)
850}
851
852/// Decide whether the model the ladder was handed is in this stage's class.
853/// Costs one `get_nlp_info`, one `get_bounds_info` and — only on a model that
854/// is square and equality-only — one starting point, one Jacobian structure
855/// query and one `∇f` evaluation.
856pub fn probe(
857    tnlp: &Rc<RefCell<dyn TNLP>>,
858    options: &OptionsList,
859) -> Result<SquareSystemPlan, SquareSystemDecline> {
860    if !is_enabled(options) {
861        return Err(SquareSystemDecline::Disabled);
862    }
863    let lo_inf = opt_num(options, "nlp_lower_bound_inf", -1e19);
864    let up_inf = opt_num(options, "nlp_upper_bound_inf", 1e19);
865    let mut t = tnlp.borrow_mut();
866    let info = t.get_nlp_info().ok_or(SquareSystemDecline::Unavailable)?;
867    let n = info.n.max(0) as usize;
868    let m = info.m.max(0) as usize;
869    if m == 0 {
870        return Err(SquareSystemDecline::NotEqualityConstrained);
871    }
872    let mut x_l = vec![0.0; n];
873    let mut x_u = vec![0.0; n];
874    let mut g_l = vec![0.0; m];
875    let mut g_u = vec![0.0; m];
876    if !t.get_bounds_info(BoundsInfo {
877        x_l: &mut x_l,
878        x_u: &mut x_u,
879        g_l: &mut g_l,
880        g_u: &mut g_u,
881    }) {
882        return Err(SquareSystemDecline::Unavailable);
883    }
884    if (0..m).any(|i| !(g_l[i].is_finite() && g_l[i] == g_u[i])) {
885        return Err(SquareSystemDecline::NotEqualityConstrained);
886    }
887    let lb: Vec<Number> = x_l
888        .iter()
889        .map(|&v| if v <= lo_inf { Number::NEG_INFINITY } else { v })
890        .collect();
891    let ub: Vec<Number> = x_u
892        .iter()
893        .map(|&v| if v >= up_inf { Number::INFINITY } else { v })
894        .collect();
895    if (0..n).any(|j| lb[j] > ub[j]) {
896        return Err(SquareSystemDecline::Unavailable);
897    }
898    let free: Vec<usize> = (0..n).filter(|&j| lb[j] < ub[j]).collect();
899    if free.len() != m {
900        return Err(SquareSystemDecline::NotSquare {
901            free: free.len(),
902            rows: m,
903        });
904    }
905    // Cheapest objective test first: a model that proves `∇f` varies is out
906    // without an evaluation.
907    if t.derivative_proofs().grad_f == DerivativeProof::Varying {
908        return Err(SquareSystemDecline::ObjectiveNotConstant);
909    }
910    let mut x = vec![0.0; n];
911    let mut z_l = vec![0.0; n];
912    let mut z_u = vec![0.0; n];
913    let mut lam = vec![0.0; m];
914    if !t.get_starting_point(StartingPoint {
915        init_x: true,
916        x: &mut x,
917        init_z: false,
918        z_l: &mut z_l,
919        z_u: &mut z_u,
920        init_lambda: false,
921        lambda: &mut lam,
922    }) || x.iter().any(|v| !v.is_finite())
923    {
924        return Err(SquareSystemDecline::Unavailable);
925    }
926    for j in 0..n {
927        if lb[j] == ub[j] {
928            x[j] = lb[j];
929        }
930    }
931    let x_scaling = x.clone();
932    project(&mut x, &lb, &ub);
933    let mut grad = vec![0.0; n];
934    if !t.eval_grad_f(&x, true, &mut grad) {
935        return Err(SquareSystemDecline::Unavailable);
936    }
937    // Exactly zero, every component: "constant" is the claim, and a small
938    // gradient is a different class (the stationarity term would then not
939    // vanish at λ = 0).
940    if grad.iter().any(|&v| v != 0.0) {
941        return Err(SquareSystemDecline::ObjectiveNotConstant);
942    }
943    let nnz = info.nnz_jac_g.max(0) as usize;
944    let mut irow = vec![0 as Index; nnz];
945    let mut jcol = vec![0 as Index; nnz];
946    if !t.eval_jac_g(
947        None,
948        false,
949        SparsityRequest::Structure {
950            irow: &mut irow,
951            jcol: &mut jcol,
952        },
953    ) {
954        return Err(SquareSystemDecline::Unavailable);
955    }
956    let index_base: Index = match info.index_style {
957        IndexStyle::Fortran => 1,
958        _ => 0,
959    };
960    let mut free_of = vec![usize::MAX; n];
961    for (k, &j) in free.iter().enumerate() {
962        free_of[j] = k;
963    }
964    let mut kept = Vec::with_capacity(nnz);
965    let mut jac_row_full = Vec::with_capacity(nnz);
966    for k in 0..nnz {
967        let i = (irow[k] - index_base) as usize;
968        let j = (jcol[k] - index_base) as usize;
969        if i >= m || j >= n {
970            return Err(SquareSystemDecline::Unavailable);
971        }
972        jac_row_full.push(i);
973        if free_of[j] != usize::MAX {
974            kept.push((k, i, free_of[j]));
975        }
976    }
977    Ok(SquareSystemPlan {
978        n_full: n,
979        m,
980        lb: free.iter().map(|&j| lb[j]).collect(),
981        ub: free.iter().map(|&j| ub[j]).collect(),
982        free,
983        x_full: x,
984        x_scaling,
985        jac_row_full,
986        g_rhs: g_l,
987        kept,
988        nnz_full: nnz,
989    })
990}
991
992/// The TNLP seen as a [`ResidualSystem`] over the free variables.
993struct TnlpResidual<'a> {
994    tnlp: &'a Rc<RefCell<dyn TNLP>>,
995    plan: &'a SquareSystemPlan,
996    x_full: Vec<Number>,
997    g: Vec<Number>,
998    jac_full: Vec<Number>,
999}
1000
1001impl TnlpResidual<'_> {
1002    fn scatter(&mut self, x: &[Number]) {
1003        for (k, &j) in self.plan.free.iter().enumerate() {
1004            self.x_full[j] = x[k];
1005        }
1006    }
1007}
1008
1009impl ResidualSystem for TnlpResidual<'_> {
1010    fn eval_c(&mut self, x: &[Number], c: &mut [Number]) -> bool {
1011        self.scatter(x);
1012        if !self
1013            .tnlp
1014            .borrow_mut()
1015            .eval_g(&self.x_full, true, &mut self.g)
1016        {
1017            return false;
1018        }
1019        for ((ci, gi), ri) in c.iter_mut().zip(&self.g).zip(&self.plan.g_rhs) {
1020            *ci = gi - ri;
1021        }
1022        true
1023    }
1024
1025    fn eval_jac(&mut self, x: &[Number], vals: &mut [Number]) -> bool {
1026        self.scatter(x);
1027        if !self.tnlp.borrow_mut().eval_jac_g(
1028            Some(&self.x_full),
1029            true,
1030            SparsityRequest::Values {
1031                values: &mut self.jac_full,
1032            },
1033        ) {
1034            return false;
1035        }
1036        for (q, &(k, _, _)) in self.plan.kept.iter().enumerate() {
1037            vals[q] = self.jac_full[k];
1038        }
1039        true
1040    }
1041}
1042
1043/// The scaling the NLP arm would have run this model under, as far as the
1044/// acceptance test and the reported statistics need it.
1045struct IpmScaling {
1046    /// Per-row `s_i` of the scaled constraint violation.
1047    row_scale: Vec<Number>,
1048    /// The gradient-based objective factor alone (`1` when none applies) —
1049    /// `SolveStatistics::final_obj_scaling_factor`.
1050    computed_obj_scale: Number,
1051    /// The whole objective multiplier, signed: the caller's
1052    /// `obj_scaling_factor` times the method's own factor.
1053    total_obj_scale: Number,
1054    /// `max |∇f|` over the free variables where gradient-based scaling
1055    /// sampled it; `NaN` when it did not run.
1056    start_obj_grad_max: Number,
1057    /// Evaluations spent measuring it: `(∇f, J)`.
1058    evals: (usize, usize),
1059}
1060
1061/// The scaling `OrigIpoptNlp::determine_scaling_from_starting_point`
1062/// computes for this model, reproduced from the same `pounce_nlp` helpers
1063/// at the same point.
1064///
1065/// * The method is resolved the way `IpoptApplication` resolves it:
1066///   `none`; `user-scaling` and `curvature-based` both read the TNLP's
1067///   `get_scaling_parameters`; anything else — `gradient-based` and the
1068///   registered-but-unimplemented `equilibration-based` — runs
1069///   gradient-based.
1070/// * Gradient-based samples `∇f` and the **full** Jacobian (fixed columns
1071///   included) at the TNLP's starting point with only the fixed variables
1072///   lifted — not at the projected start the LM iterates from.
1073///   [`gradient_scaling_fires`] decides whether the rows get a vector at
1074///   all, and [`gradient_row_scale`] gives each row its factor.
1075/// * User scaling floors every `g_scaling` entry at
1076///   `nlp_scaling_min_value`, and falls back to no scaling when the TNLP
1077///   declines, as the IPM does.
1078///
1079/// What is *not* reproduced: a wrapper the application inserts inside
1080/// `optimize_tnlp` (the start conditioner, the gh#486 variable-scaling
1081/// substitution) can move the start the IPM sampled. The unscaled
1082/// `constr_viol_tol` half of the test does not depend on any of this, so a
1083/// residual difference in `s_i` can change only how far below `tol` the
1084/// scaled half is met, never admit a point the unscaled half rejects.
1085///
1086/// [`gradient_scaling_fires`]: pounce_nlp::orig_ipopt_nlp::gradient_scaling_fires
1087/// [`gradient_row_scale`]: pounce_nlp::orig_ipopt_nlp::gradient_row_scale
1088fn ipm_scaling(
1089    tnlp: &Rc<RefCell<dyn TNLP>>,
1090    options: &OptionsList,
1091    plan: &SquareSystemPlan,
1092) -> IpmScaling {
1093    use pounce_nlp::orig_ipopt_nlp::{
1094        gradient_obj_scale, gradient_row_scale, gradient_scaling_fires,
1095    };
1096    let m = plan.m;
1097    let user_obj = opt_num(options, "obj_scaling_factor", 1.0);
1098    let min_value = opt_num(options, "nlp_scaling_min_value", 1e-8);
1099    let unscaled = IpmScaling {
1100        row_scale: vec![1.0; m],
1101        computed_obj_scale: 1.0,
1102        total_obj_scale: user_obj,
1103        start_obj_grad_max: Number::NAN,
1104        evals: (0, 0),
1105    };
1106    let method = options
1107        .get_string_value("nlp_scaling_method", "")
1108        .ok()
1109        .and_then(|(v, found)| found.then_some(v))
1110        .unwrap_or_else(|| "gradient-based".to_string());
1111    match method.as_str() {
1112        "none" => unscaled,
1113        "user-scaling" | "curvature-based" => {
1114            let mut obj = 1.0;
1115            let mut use_x = false;
1116            let mut xs = vec![1.0; plan.n_full];
1117            let mut use_g = false;
1118            let mut gs = vec![1.0; m];
1119            let ok = tnlp.borrow_mut().get_scaling_parameters(ScalingRequest {
1120                obj_scaling: &mut obj,
1121                use_x_scaling: &mut use_x,
1122                x_scaling: &mut xs,
1123                use_g_scaling: &mut use_g,
1124                g_scaling: &mut gs,
1125            });
1126            if !ok {
1127                return unscaled;
1128            }
1129            let df = if obj.abs() < min_value {
1130                min_value
1131            } else {
1132                obj
1133            };
1134            IpmScaling {
1135                row_scale: if use_g {
1136                    gs.iter()
1137                        .map(|&v| if v < min_value { min_value } else { v })
1138                        .collect()
1139                } else {
1140                    vec![1.0; m]
1141                },
1142                computed_obj_scale: 1.0,
1143                total_obj_scale: df * user_obj,
1144                start_obj_grad_max: Number::NAN,
1145                evals: (0, 0),
1146            }
1147        }
1148        _ => {
1149            let max_grad = opt_num(options, "nlp_scaling_max_gradient", 100.0);
1150            let obj_target = opt_num(options, "nlp_scaling_obj_target_gradient", 0.0);
1151            let constr_target = opt_num(options, "nlp_scaling_constr_target_gradient", 0.0);
1152            let x = &plan.x_scaling;
1153            let mut grad = vec![0.0; plan.n_full];
1154            let mut df = 1.0;
1155            let mut grad_max = Number::NAN;
1156            if tnlp.borrow_mut().eval_grad_f(x, true, &mut grad) {
1157                let gm = plan
1158                    .free
1159                    .iter()
1160                    .fold(0.0 as Number, |a, &j| a.max(grad[j].abs()));
1161                grad_max = gm;
1162                if gm.is_finite() {
1163                    df = gradient_obj_scale(gm, max_grad, min_value, obj_target);
1164                }
1165            }
1166            let mut vals = vec![0.0; plan.nnz_full];
1167            let row_scale = if tnlp.borrow_mut().eval_jac_g(
1168                Some(x),
1169                true,
1170                SparsityRequest::Values { values: &mut vals },
1171            ) {
1172                let mut row_max = vec![Number::MIN_POSITIVE; m];
1173                for (k, &i) in plan.jac_row_full.iter().enumerate() {
1174                    row_max[i] = row_max[i].max(vals[k].abs());
1175                }
1176                if gradient_scaling_fires(&row_max, max_grad, constr_target) {
1177                    row_max
1178                        .iter()
1179                        .map(|&g| gradient_row_scale(g, max_grad, min_value, constr_target))
1180                        .collect()
1181                } else {
1182                    vec![1.0; m]
1183                }
1184            } else {
1185                vec![1.0; m]
1186            };
1187            IpmScaling {
1188                row_scale,
1189                computed_obj_scale: df,
1190                total_obj_scale: df * user_obj,
1191                start_obj_grad_max: grad_max,
1192                evals: (1, 1),
1193            }
1194        }
1195    }
1196}
1197
1198/// What [`run`] reports.
1199#[derive(Debug, Clone)]
1200pub struct SquareSystemOutcome {
1201    pub lm: LmResult,
1202    /// The certificate held and the solution was handed to the TNLP's
1203    /// `finalize_solution`.
1204    pub accepted: bool,
1205    /// One line for the console when not accepted.
1206    pub reason: String,
1207    /// Statistics for the promoted verdict, populated on acceptance.
1208    pub statistics: SolveStatistics,
1209    /// `(scaled, unscaled)` violation at the returned point.
1210    pub violation: (Number, Number),
1211}
1212
1213/// How [`run`] is to treat its caller's per-iteration surfaces.
1214#[derive(Debug, Clone, Copy, Default)]
1215pub struct RunOptions {
1216    /// Fill `SolveStatistics::iterations` with one record per LM iterate
1217    /// (the start included, so `iterations.len() == iteration_count + 1`,
1218    /// the IPM's convention) — set it when the application records
1219    /// iteration history, so a promoted verdict keeps the
1220    /// `iterations`/`iteration_count` pairing every other verdict has.
1221    pub capture_iterations: bool,
1222}
1223
1224/// Run the LM on a probed model and, if the result passes every check in the
1225/// module docs, finalize it through `tnlp` as `Solve_Succeeded`.
1226///
1227/// On any failure `tnlp` receives no `finalize_solution` call, so the caller's
1228/// captured solution stays the one its original verdict describes.
1229///
1230/// The LM honours `max_cpu_time` / `max_wall_time` as a budget of its own,
1231/// measured from this call — the same per-solve budget each option rung's
1232/// `optimize_tnlp` gets — and fires the TNLP's `intermediate_callback` at
1233/// every iterate (unless the TNLP says it does not want one), stopping on a
1234/// `false`. Running out of time and a stop request both end the stage
1235/// without a promotion.
1236pub fn run(
1237    plan: &SquareSystemPlan,
1238    tnlp: &Rc<RefCell<dyn TNLP>>,
1239    options: &OptionsList,
1240    backend: Box<dyn SparseSymLinearSolverInterface>,
1241    run_opts: RunOptions,
1242) -> SquareSystemOutcome {
1243    use pounce_nlp::solve_statistics::IterRecord;
1244    let wall0 = pounce_common::utils::wallclock_time();
1245    let cpu0 = pounce_common::utils::cpu_time();
1246    let deadline = pounce_common::timing::Deadline::new(
1247        opt_num(options, "max_wall_time", 1e6),
1248        opt_num(options, "max_cpu_time", 1e6),
1249    );
1250    let max_iter = options
1251        .get_integer_value("max_iter", "")
1252        .ok()
1253        .and_then(|(v, found)| found.then_some(v.max(0) as usize))
1254        .unwrap_or(3000)
1255        .min(LM_ITERATION_CAP);
1256    let prob = LmProblem {
1257        n: plan.free.len(),
1258        m: plan.m,
1259        jac_rows: plan.kept.iter().map(|&(_, i, _)| i).collect(),
1260        jac_cols: plan.kept.iter().map(|&(_, _, j)| j).collect(),
1261        lb: plan.lb.clone(),
1262        ub: plan.ub.clone(),
1263    };
1264    let x0: Vec<Number> = plan.free.iter().map(|&j| plan.x_full[j]).collect();
1265    let mut model = TnlpResidual {
1266        tnlp,
1267        plan,
1268        x_full: plan.x_full.clone(),
1269        g: vec![0.0; plan.m],
1270        jac_full: vec![0.0; plan.nnz_full],
1271    };
1272    let fail = |lm: LmResult, reason: String| SquareSystemOutcome {
1273        violation: (Number::NAN, Number::NAN),
1274        lm,
1275        accepted: false,
1276        reason,
1277        statistics: SolveStatistics::default(),
1278    };
1279    let scaling = ipm_scaling(tnlp, options, plan);
1280    let test = FeasibilityTest {
1281        row_scale: scaling.row_scale.clone(),
1282        tol: opt_num(options, "tol", 1e-8),
1283        constr_viol_tol: opt_num(options, "constr_viol_tol", 1e-4),
1284    };
1285
1286    // The per-iterate observer: the user's `intermediate_callback` and the
1287    // iteration record. Each needs `f` and `∇f` at the iterate, which the LM
1288    // itself never evaluates, so they are evaluated only when one of the two
1289    // is wanted — and counted.
1290    let wants_callback = tnlp.borrow().wants_intermediate_callback();
1291    let mut records: Vec<IterRecord> = Vec::new();
1292    let mut extra_f_evals = 0usize;
1293    let mut extra_grad_evals = 0usize;
1294    let mut observe = |it: &LmIterate<'_>| -> bool {
1295        if !(wants_callback || run_opts.capture_iterations) {
1296            return true;
1297        }
1298        let mut xf = plan.x_full.clone();
1299        for (k, &j) in plan.free.iter().enumerate() {
1300            xf[j] = it.x[k];
1301        }
1302        extra_f_evals += 1;
1303        let obj = tnlp.borrow_mut().eval_f(&xf, true).unwrap_or(Number::NAN);
1304        let mut grad = vec![0.0; plan.n_full];
1305        extra_grad_evals += 1;
1306        let inf_du = if tnlp.borrow_mut().eval_grad_f(&xf, false, &mut grad) {
1307            plan.free
1308                .iter()
1309                .fold(0.0 as Number, |a, &j| a.max(grad[j].abs()))
1310        } else {
1311            Number::NAN
1312        };
1313        let (scaled, unscaled) = test.violations(it.c);
1314        let alpha = if it.accepted { 1.0 } else { 0.0 };
1315        if run_opts.capture_iterations {
1316            records.push(IterRecord {
1317                iter: it.iter as Index,
1318                objective: obj,
1319                inf_pr: unscaled,
1320                inf_pr_internal: scaled,
1321                inf_du,
1322                mu: 0.0,
1323                d_norm: it.step_inf_norm,
1324                regularization: it.lambda,
1325                alpha_dual: 0.0,
1326                alpha_primal: alpha,
1327                alpha_primal_char: if it.iter == 0 { ' ' } else { 'L' },
1328                ls_trials: 0,
1329                phase: Default::default(),
1330            });
1331        }
1332        if wants_callback {
1333            return tnlp.borrow_mut().intermediate_callback(
1334                pounce_nlp::tnlp::IterStats {
1335                    mode: pounce_nlp::AlgorithmMode::RegularMode,
1336                    iter: it.iter as Index,
1337                    obj_value: obj,
1338                    inf_pr: scaled,
1339                    inf_du,
1340                    mu: 0.0,
1341                    d_norm: it.step_inf_norm,
1342                    regularization_size: it.lambda,
1343                    alpha_du: 0.0,
1344                    alpha_pr: alpha,
1345                    ls_trials: 0,
1346                },
1347                &IpoptData::default(),
1348                &IpoptCq::default(),
1349            );
1350        }
1351        true
1352    };
1353    let lm = bounded_levenberg_marquardt(
1354        &mut model,
1355        &prob,
1356        &x0,
1357        &test,
1358        max_iter,
1359        backend,
1360        LmHooks {
1361            deadline: Some(deadline),
1362            on_iterate: Some(&mut observe),
1363        },
1364    );
1365    if lm.status != LmStatus::Converged {
1366        let reason = match lm.status {
1367            LmStatus::CpuTimeExceeded => format!(
1368                "max_cpu_time reached after {} iteration(s), before a root",
1369                lm.iterations
1370            ),
1371            LmStatus::WallTimeExceeded => format!(
1372                "max_wall_time reached after {} iteration(s), before a root",
1373                lm.iterations
1374            ),
1375            LmStatus::UserRequestedStop => format!(
1376                "the intermediate callback requested a stop after {} iteration(s)",
1377                lm.iterations
1378            ),
1379            st => format!("{st:?} after {} iteration(s)", lm.iterations),
1380        };
1381        return fail(lm, reason);
1382    }
1383
1384    // The certificate, re-checked at the returned point from fresh
1385    // evaluations rather than read off the LM's bookkeeping.
1386    model.scatter(&lm.x);
1387    let x_full = model.x_full.clone();
1388    let mut g = vec![0.0; plan.m];
1389    let mut c = vec![0.0; plan.m];
1390    if !tnlp.borrow_mut().eval_g(&x_full, true, &mut g) {
1391        return fail(lm, "eval_g failed at the returned point".into());
1392    }
1393    for i in 0..plan.m {
1394        c[i] = g[i] - plan.g_rhs[i];
1395    }
1396    if !test.passes(&c) {
1397        return fail(
1398            lm,
1399            "the returned point does not pass the feasibility test".into(),
1400        );
1401    }
1402    if (0..prob.n).any(|k| !(lm.x[k] >= prob.lb[k] && lm.x[k] <= prob.ub[k])) {
1403        return fail(lm, "the returned point is outside the variable box".into());
1404    }
1405    let mut grad = vec![0.0; plan.n_full];
1406    if !tnlp.borrow_mut().eval_grad_f(&x_full, false, &mut grad) || grad.iter().any(|&v| v != 0.0) {
1407        return fail(
1408            lm,
1409            "the objective gradient is nonzero at the returned point".into(),
1410        );
1411    }
1412    let Some(obj) = tnlp.borrow_mut().eval_f(&x_full, false) else {
1413        return fail(lm, "eval_f failed at the returned point".into());
1414    };
1415    if !obj.is_finite() {
1416        return fail(
1417            lm,
1418            "the objective is not finite at the returned point".into(),
1419        );
1420    }
1421
1422    let (scaled, unscaled) = test.violations(&c);
1423    let zeros_n = vec![0.0; plan.n_full];
1424    let zeros_m = vec![0.0; plan.m];
1425    tnlp.borrow_mut().finalize_solution(
1426        Solution {
1427            status: SolverReturn::Success,
1428            x: &x_full,
1429            z_l: &zeros_n,
1430            z_u: &zeros_n,
1431            g: &g,
1432            lambda: &zeros_m,
1433            obj_value: obj,
1434        },
1435        &IpoptData::default(),
1436        &IpoptCq::default(),
1437    );
1438
1439    // Recorded iterates go to any enclosing capture as well, the way
1440    // `optimize_tnlp` forwards its own.
1441    pounce_observability::extend_active_capture(&records);
1442    let statistics = SolveStatistics {
1443        iteration_count: lm.iterations as Index,
1444        total_cpu_time_secs: pounce_common::utils::cpu_time() - cpu0,
1445        total_wallclock_time_secs: pounce_common::utils::wallclock_time() - wall0,
1446        // The certificate's `f` and `∇f` plus the observer's and the
1447        // scaling measurement's, and the certificate's `g` on top of the
1448        // LM's own.
1449        num_obj_evals: (1 + extra_f_evals) as Index,
1450        num_constr_evals: (lm.residual_evals + 1) as Index,
1451        num_obj_grad_evals: (1 + extra_grad_evals + scaling.evals.0) as Index,
1452        num_constr_jac_evals: (lm.jacobian_evals + scaling.evals.1) as Index,
1453        final_objective: obj,
1454        // In the units the IPM reports it in: `total · f`.
1455        final_scaled_objective: scaling.total_obj_scale * obj,
1456        final_obj_scaling_factor: scaling.computed_obj_scale,
1457        final_total_obj_scaling_factor: scaling.total_obj_scale.abs(),
1458        start_obj_grad_max: scaling.start_obj_grad_max,
1459        final_dual_inf: 0.0,
1460        final_constr_viol: scaled,
1461        final_compl: 0.0,
1462        final_kkt_error: scaled,
1463        final_unscaled_dual_inf: 0.0,
1464        final_unscaled_constr_viol: unscaled,
1465        final_unscaled_compl: 0.0,
1466        final_unscaled_kkt_error: unscaled,
1467        final_kkt_error_above_noise: scaled,
1468        final_declared_constr_viol: unscaled,
1469        final_declared_box_viol: 0.0,
1470        final_mu: 0.0,
1471        iterations: records,
1472        ..SolveStatistics::default()
1473    };
1474    SquareSystemOutcome {
1475        lm,
1476        accepted: true,
1477        reason: String::new(),
1478        statistics,
1479        violation: (scaled, unscaled),
1480    }
1481}
1482
1483/// The backend the stage factors with: FERAL, configured from the same
1484/// `feral_*` options as the IPM, and with **no** summary sink, so the
1485/// linear-solver summary a caller reads keeps describing the interior-point
1486/// solves rather than absorbing this stage's factorizations.
1487pub fn default_backend(options: &OptionsList) -> Box<dyn SparseSymLinearSolverInterface> {
1488    Box::new(pounce_feral::FeralSolverInterface::with_config(
1489        crate::application::feral_config_from_options(options),
1490    ))
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496
1497    /// Dense-pattern test system from a closure pair.
1498    struct Sys<F, J>
1499    where
1500        F: FnMut(&[Number], &mut [Number]),
1501        J: FnMut(&[Number], &mut [Number]),
1502    {
1503        f: F,
1504        j: J,
1505    }
1506
1507    impl<F, J> ResidualSystem for Sys<F, J>
1508    where
1509        F: FnMut(&[Number], &mut [Number]),
1510        J: FnMut(&[Number], &mut [Number]),
1511    {
1512        fn eval_c(&mut self, x: &[Number], c: &mut [Number]) -> bool {
1513            (self.f)(x, c);
1514            true
1515        }
1516        fn eval_jac(&mut self, x: &[Number], v: &mut [Number]) -> bool {
1517            (self.j)(x, v);
1518            true
1519        }
1520    }
1521
1522    fn dense2(lb: [Number; 2], ub: [Number; 2]) -> LmProblem {
1523        LmProblem {
1524            n: 2,
1525            m: 2,
1526            jac_rows: vec![0, 0, 1, 1],
1527            jac_cols: vec![0, 1, 0, 1],
1528            lb: lb.to_vec(),
1529            ub: ub.to_vec(),
1530        }
1531    }
1532
1533    fn strict() -> FeasibilityTest {
1534        FeasibilityTest {
1535            row_scale: vec![1.0, 1.0],
1536            tol: 1e-10,
1537            constr_viol_tol: 1e-10,
1538        }
1539    }
1540
1541    fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
1542        Box::new(pounce_feral::FeralSolverInterface::with_config(
1543            pounce_feral::FeralConfig::default(),
1544        ))
1545    }
1546
1547    /// The root sits **on** a bound, and the unconstrained Newton steps from
1548    /// this start overshoot it: `x0² + x1² = 2`, `x0 = x1`, `x0 ≤ 1`, from
1549    /// `(0.2, 3)`. Projection must clip the overshoot onto the bound and the
1550    /// method must still converge — to the root on the bound, not to the
1551    /// mirror root `(−1, −1)` and not to a point outside the box.
1552    #[test]
1553    fn converges_to_a_root_on_an_active_bound() {
1554        let prob = dense2([-10.0, -10.0], [1.0, 10.0]);
1555        let mut sys = Sys {
1556            f: |x: &[Number], c: &mut [Number]| {
1557                c[0] = x[0] * x[0] + x[1] * x[1] - 2.0;
1558                c[1] = x[0] - x[1];
1559            },
1560            j: |x: &[Number], v: &mut [Number]| {
1561                v.copy_from_slice(&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0]);
1562            },
1563        };
1564        let r = bounded_levenberg_marquardt(
1565            &mut sys,
1566            &prob,
1567            &[0.2, 3.0],
1568            &strict(),
1569            200,
1570            backend(),
1571            LmHooks::default(),
1572        );
1573        assert_eq!(r.status, LmStatus::Converged, "{r:?}");
1574        assert!(r.x[0] <= 1.0, "x0 = {} left the box", r.x[0]);
1575        assert!(
1576            (r.x[0] - 1.0).abs() < 1e-9 && (r.x[1] - 1.0).abs() < 1e-9,
1577            "{:?}",
1578            r.x
1579        );
1580    }
1581
1582    /// The honesty half of the bound case: the only root is outside the box,
1583    /// so the method must say so rather than report the clipped point.
1584    /// `x0 = 2`, `x1 = 0` over `x0 ≤ 1` has no root in the box; the
1585    /// least-squares minimum on the box is `(1, 0)` with `|c| = 1`.
1586    #[test]
1587    fn a_root_outside_the_box_is_not_reported_as_converged() {
1588        let prob = dense2([-10.0, -10.0], [1.0, 10.0]);
1589        let mut sys = Sys {
1590            f: |x: &[Number], c: &mut [Number]| {
1591                c[0] = x[0] - 2.0;
1592                c[1] = x[1];
1593            },
1594            j: |_x: &[Number], v: &mut [Number]| v.copy_from_slice(&[1.0, 0.0, 0.0, 1.0]),
1595        };
1596        let r = bounded_levenberg_marquardt(
1597            &mut sys,
1598            &prob,
1599            &[0.0, 0.5],
1600            &strict(),
1601            200,
1602            backend(),
1603            LmHooks::default(),
1604        );
1605        assert_eq!(r.status, LmStatus::Stationary, "{r:?}");
1606        assert!(r.x[0] <= 1.0);
1607        assert!(
1608            (r.c[0] + 1.0).abs() < 1e-8,
1609            "should stall at the box's nearest point: {r:?}"
1610        );
1611    }
1612
1613    /// `degenerate_start_infeasible`'s shape: `x0² + x1² = −25`, `x0·x1 = 9`
1614    /// from the origin, where `J = 0`. No step leaves a point whose gradient
1615    /// is exactly zero, so the method must stop at once on stationarity —
1616    /// not report the origin, and not spend its budget halving `Δ`.
1617    #[test]
1618    fn a_zero_jacobian_start_stops_on_stationarity_at_once() {
1619        let prob = dense2([Number::NEG_INFINITY; 2], [Number::INFINITY; 2]);
1620        let mut sys = Sys {
1621            f: |x: &[Number], c: &mut [Number]| {
1622                c[0] = x[0] * x[0] + x[1] * x[1] + 25.0;
1623                c[1] = x[0] * x[1] - 9.0;
1624            },
1625            j: |x: &[Number], v: &mut [Number]| {
1626                v.copy_from_slice(&[2.0 * x[0], 2.0 * x[1], x[1], x[0]]);
1627            },
1628        };
1629        let r = bounded_levenberg_marquardt(
1630            &mut sys,
1631            &prob,
1632            &[0.0, 0.0],
1633            &strict(),
1634            200,
1635            backend(),
1636            LmHooks::default(),
1637        );
1638        assert_eq!(r.status, LmStatus::Stationary, "{r:?}");
1639        assert_eq!(r.iterations, 0);
1640    }
1641
1642    /// Rank-deficient everywhere on the solution set: `x0 = x1` and
1643    /// `x0² = x1²` share the root set `{x0 = x1}`, and the Jacobian
1644    /// `[[1, −1], [2x0, −2x1]]` is singular at every root. Gauss–Newton's
1645    /// system is singular there; the LM's `λ` keeps the augmented factor
1646    /// well-posed and the method still drives `‖c‖` to zero (the
1647    /// error-bound regime of Yamashita & Fukushima). The root it lands on
1648    /// is not unique, so only feasibility is asserted.
1649    #[test]
1650    fn converges_on_a_rank_deficient_non_isolated_root_set() {
1651        let prob = dense2([Number::NEG_INFINITY; 2], [Number::INFINITY; 2]);
1652        let mut sys = Sys {
1653            f: |x: &[Number], c: &mut [Number]| {
1654                c[0] = x[0] - x[1];
1655                c[1] = x[0] * x[0] - x[1] * x[1];
1656            },
1657            j: |x: &[Number], v: &mut [Number]| {
1658                v.copy_from_slice(&[1.0, -1.0, 2.0 * x[0], -2.0 * x[1]]);
1659            },
1660        };
1661        let r = bounded_levenberg_marquardt(
1662            &mut sys,
1663            &prob,
1664            &[3.0, -1.0],
1665            &strict(),
1666            200,
1667            backend(),
1668            LmHooks::default(),
1669        );
1670        assert_eq!(r.status, LmStatus::Converged, "{r:?}");
1671        assert!((r.x[0] - r.x[1]).abs() < 1e-10, "{:?}", r.x);
1672    }
1673
1674    /// The same deficiency, structurally: a duplicated row (`J` has rank 1
1675    /// at every point) with duplicate triplets in the pattern, which must be
1676    /// summed rather than double-placed in the augmented system.
1677    #[test]
1678    fn duplicate_triplets_are_summed_and_a_repeated_row_is_tolerated() {
1679        let prob = LmProblem {
1680            n: 2,
1681            m: 2,
1682            // Row 0 lists (0, 0) twice, half the coefficient each time.
1683            jac_rows: vec![0, 0, 0, 1, 1],
1684            jac_cols: vec![0, 0, 1, 0, 1],
1685            lb: vec![Number::NEG_INFINITY; 2],
1686            ub: vec![Number::INFINITY; 2],
1687        };
1688        let mut sys = Sys {
1689            f: |x: &[Number], c: &mut [Number]| {
1690                c[0] = x[0] + x[1] - 2.0;
1691                c[1] = 2.0 * (x[0] + x[1] - 2.0);
1692            },
1693            j: |_x: &[Number], v: &mut [Number]| v.copy_from_slice(&[0.5, 0.5, 1.0, 2.0, 2.0]),
1694        };
1695        let r = bounded_levenberg_marquardt(
1696            &mut sys,
1697            &prob,
1698            &[5.0, 7.0],
1699            &strict(),
1700            200,
1701            backend(),
1702            LmHooks::default(),
1703        );
1704        assert_eq!(r.status, LmStatus::Converged, "{r:?}");
1705        assert!((r.x[0] + r.x[1] - 2.0).abs() < 1e-10);
1706    }
1707
1708    /// The bound-root system of `converges_to_a_root_on_an_active_bound`,
1709    /// shared by the budget and callback tests below so each differs from a
1710    /// converging run in exactly the hook it exercises.
1711    fn bound_root_system() -> (
1712        LmProblem,
1713        Sys<impl FnMut(&[Number], &mut [Number]), impl FnMut(&[Number], &mut [Number])>,
1714    ) {
1715        let prob = dense2([-10.0, -10.0], [1.0, 10.0]);
1716        let sys = Sys {
1717            f: |x: &[Number], c: &mut [Number]| {
1718                c[0] = x[0] * x[0] + x[1] * x[1] - 2.0;
1719                c[1] = x[0] - x[1];
1720            },
1721            j: |x: &[Number], v: &mut [Number]| {
1722                v.copy_from_slice(&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0]);
1723            },
1724        };
1725        (prob, sys)
1726    }
1727
1728    /// An exhausted time budget stops the method before its first step, and
1729    /// says which budget. `Deadline::new(0, ·)` is exceeded on its first
1730    /// read whatever the clock resolution (`elapsed ≥ 0`), so this is
1731    /// deterministic. Mutation-checked: with the `time_exceeded` checks
1732    /// removed from `bounded_levenberg_marquardt` both arms run to
1733    /// `Converged` and the test fails.
1734    #[test]
1735    fn an_exhausted_time_budget_stops_the_method_and_names_the_budget() {
1736        use pounce_common::timing::Deadline;
1737        for (deadline, want) in [
1738            (Deadline::new(0.0, 1e6), LmStatus::WallTimeExceeded),
1739            (Deadline::new(1e6, 0.0), LmStatus::CpuTimeExceeded),
1740        ] {
1741            let (prob, mut sys) = bound_root_system();
1742            let r = bounded_levenberg_marquardt(
1743                &mut sys,
1744                &prob,
1745                &[0.2, 3.0],
1746                &strict(),
1747                200,
1748                backend(),
1749                LmHooks {
1750                    deadline: Some(deadline),
1751                    on_iterate: None,
1752                },
1753            );
1754            assert_eq!(r.status, want, "{r:?}");
1755            assert_eq!(r.iterations, 0, "{r:?}");
1756        }
1757    }
1758
1759    /// The clock is read after the convergence test, so a start that is
1760    /// already a root is reported as one even with no time left — the same
1761    /// order as the IPM's check. Mutation-checked: moving the iterate-0
1762    /// clock check above `test.passes` turns this `WallTimeExceeded`.
1763    #[test]
1764    fn a_root_is_not_discarded_for_being_late() {
1765        let (prob, mut sys) = bound_root_system();
1766        let r = bounded_levenberg_marquardt(
1767            &mut sys,
1768            &prob,
1769            &[1.0, 1.0],
1770            &strict(),
1771            200,
1772            backend(),
1773            LmHooks {
1774                deadline: Some(pounce_common::timing::Deadline::new(0.0, 0.0)),
1775                on_iterate: None,
1776            },
1777        );
1778        assert_eq!(r.status, LmStatus::Converged, "{r:?}");
1779        assert_eq!(r.iterations, 0);
1780    }
1781
1782    /// The observer sees every iterate from the start on, in order, and a
1783    /// `false` stops the method on that iterate. Mutation-checked: ignoring
1784    /// the observer's return value runs to `Converged` and fails here. (Every
1785    /// step of this system is accepted, so the test cannot tell an observer
1786    /// fired on every iterate from one fired on accepted steps only; that
1787    /// mutation was tried and survives.)
1788    #[test]
1789    fn the_observer_sees_every_iterate_and_can_stop_the_method() {
1790        let (prob, mut sys) = bound_root_system();
1791        let mut seen: Vec<usize> = Vec::new();
1792        let mut obs = |it: &LmIterate<'_>| {
1793            seen.push(it.iter);
1794            it.iter < 2
1795        };
1796        let r = bounded_levenberg_marquardt(
1797            &mut sys,
1798            &prob,
1799            &[0.2, 3.0],
1800            &strict(),
1801            200,
1802            backend(),
1803            LmHooks {
1804                deadline: None,
1805                on_iterate: Some(&mut obs),
1806            },
1807        );
1808        assert_eq!(r.status, LmStatus::UserRequestedStop, "{r:?}");
1809        assert_eq!(r.iterations, 2);
1810        assert_eq!(seen, [0, 1, 2]);
1811
1812        // Unstopped, it sees iterates 0..=iterations: one per iteration
1813        // plus the start, the IPM's record convention.
1814        let (prob, mut sys) = bound_root_system();
1815        let mut seen: Vec<usize> = Vec::new();
1816        let mut obs = |it: &LmIterate<'_>| {
1817            seen.push(it.iter);
1818            true
1819        };
1820        let r = bounded_levenberg_marquardt(
1821            &mut sys,
1822            &prob,
1823            &[0.2, 3.0],
1824            &strict(),
1825            200,
1826            backend(),
1827            LmHooks {
1828                deadline: None,
1829                on_iterate: Some(&mut obs),
1830            },
1831        );
1832        assert_eq!(r.status, LmStatus::Converged, "{r:?}");
1833        assert_eq!(seen, (0..=r.iterations).collect::<Vec<_>>());
1834    }
1835
1836    /// A 3-variable TNLP for [`run`]: `x1` fixed at 3 in the **middle**
1837    /// slot (so a full/free index mix-up lands a value in the wrong place),
1838    /// `x0 ≤ 2` active at the root `(2, 3, 2.5)`, rows `x0·x2 − x1 = 2` and
1839    /// `x0 + x1/2 − x2 = 1`, objective the constant 5. Counts what the
1840    /// stage does to it.
1841    struct Fxm {
1842        finalized: Vec<Vec<Number>>,
1843        callbacks: usize,
1844        /// Callback returns `false` from this iterate on.
1845        stop_at: Option<Index>,
1846    }
1847
1848    impl Fxm {
1849        fn new() -> Self {
1850            Self {
1851                finalized: Vec::new(),
1852                callbacks: 0,
1853                stop_at: None,
1854            }
1855        }
1856    }
1857
1858    impl TNLP for Fxm {
1859        fn get_nlp_info(&mut self) -> Option<pounce_nlp::tnlp::NlpInfo> {
1860            Some(pounce_nlp::tnlp::NlpInfo {
1861                n: 3,
1862                m: 2,
1863                nnz_jac_g: 6,
1864                nnz_h_lag: 0,
1865                index_style: IndexStyle::C,
1866            })
1867        }
1868        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1869            b.x_l.copy_from_slice(&[-1e20, 3.0, -1e20]);
1870            b.x_u.copy_from_slice(&[2.0, 3.0, 1e20]);
1871            b.g_l.copy_from_slice(&[2.0, 1.0]);
1872            b.g_u.copy_from_slice(&[2.0, 1.0]);
1873            true
1874        }
1875        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1876            sp.x.copy_from_slice(&[1.5, 7.0, 1.5]);
1877            true
1878        }
1879        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
1880            Some(5.0)
1881        }
1882        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1883            g.iter_mut().for_each(|v| *v = 0.0);
1884            true
1885        }
1886        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1887            g[0] = x[0] * x[2] - x[1];
1888            g[1] = x[0] + 0.5 * x[1] - x[2];
1889            true
1890        }
1891        fn eval_jac_g(
1892            &mut self,
1893            x: Option<&[Number]>,
1894            _new_x: bool,
1895            mode: SparsityRequest<'_>,
1896        ) -> bool {
1897            match mode {
1898                SparsityRequest::Structure { irow, jcol } => {
1899                    irow.copy_from_slice(&[0, 0, 0, 1, 1, 1]);
1900                    jcol.copy_from_slice(&[0, 1, 2, 0, 1, 2]);
1901                }
1902                SparsityRequest::Values { values } => {
1903                    let x = x.unwrap();
1904                    values.copy_from_slice(&[x[2], -1.0, x[0], 1.0, 0.5, -1.0]);
1905                }
1906            }
1907            true
1908        }
1909        fn intermediate_callback(
1910            &mut self,
1911            stats: pounce_nlp::tnlp::IterStats,
1912            _d: &IpoptData,
1913            _q: &IpoptCq,
1914        ) -> bool {
1915            self.callbacks += 1;
1916            self.stop_at.is_none_or(|k| stats.iter < k)
1917        }
1918        fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
1919            self.finalized.push(sol.x.to_vec());
1920        }
1921    }
1922
1923    fn run_fxm(
1924        fxm: Fxm,
1925        opts: &[(&str, Number)],
1926        capture: bool,
1927    ) -> (SquareSystemOutcome, Rc<RefCell<Fxm>>) {
1928        let mut options = OptionsList::new();
1929        for &(tag, v) in opts {
1930            options.set_numeric_value(tag, v, true, true).unwrap();
1931        }
1932        let cell = Rc::new(RefCell::new(fxm));
1933        let tnlp: Rc<RefCell<dyn TNLP>> = cell.clone();
1934        let plan = probe(&tnlp, &options).expect("Fxm is in the class");
1935        let out = run(
1936            &plan,
1937            &tnlp,
1938            &options,
1939            backend(),
1940            RunOptions {
1941                capture_iterations: capture,
1942            },
1943        );
1944        (out, cell)
1945    }
1946
1947    /// The baseline the two tests below each break one hook of: promoted,
1948    /// finalized once, with the fixed variable in its own (middle) slot, a
1949    /// callback per iterate, `iterations.len() == iteration_count + 1`, and
1950    /// `final_scaled_objective` carrying `obj_scaling_factor`.
1951    #[test]
1952    fn run_promotes_and_reports_in_the_ipm_conventions() {
1953        let (out, fxm) = run_fxm(Fxm::new(), &[("obj_scaling_factor", 2.0)], true);
1954        assert!(out.accepted, "{}", out.reason);
1955        let fxm = fxm.borrow();
1956        assert_eq!(fxm.finalized.len(), 1);
1957        let x = &fxm.finalized[0];
1958        assert_eq!(x[1], 3.0, "the fixed variable keeps its slot: {x:?}");
1959        assert!(
1960            (x[0] - 2.0).abs() < 1e-12 && (x[2] - 2.5).abs() < 1e-12,
1961            "{x:?}"
1962        );
1963        let st = &out.statistics;
1964        assert_eq!(st.iterations.len(), st.iteration_count as usize + 1);
1965        assert_eq!(fxm.callbacks, st.iteration_count as usize + 1);
1966        assert_eq!(st.final_objective, 5.0);
1967        assert_eq!(st.final_scaled_objective, 10.0);
1968    }
1969
1970    /// `max_wall_time` reaches the LM through [`run`], and running out
1971    /// means no promotion and no `finalize_solution`. `0` is exceeded on the
1972    /// first read of the clock, so this is deterministic. Mutation-checked:
1973    /// passing `deadline: None` in `run` promotes and the test fails.
1974    #[test]
1975    fn run_honours_max_wall_time_and_does_not_promote() {
1976        for (tag, want) in [
1977            ("max_wall_time", LmStatus::WallTimeExceeded),
1978            ("max_cpu_time", LmStatus::CpuTimeExceeded),
1979        ] {
1980            let (out, fxm) = run_fxm(Fxm::new(), &[(tag, 0.0)], true);
1981            assert!(!out.accepted, "{tag}");
1982            assert_eq!(out.lm.status, want, "{tag}");
1983            assert!(out.reason.contains(tag), "{tag}: {}", out.reason);
1984            assert!(fxm.borrow().finalized.is_empty(), "{tag}");
1985        }
1986    }
1987
1988    /// The TNLP's intermediate callback is fired at every iterate and its
1989    /// stop request is honoured: no promotion, no `finalize_solution`.
1990    /// Mutation-checked: discarding the callback's return value in `run`'s
1991    /// observer promotes and the test fails.
1992    #[test]
1993    fn run_honours_an_intermediate_callback_stop() {
1994        let mut fxm = Fxm::new();
1995        fxm.stop_at = Some(1);
1996        let (out, fxm) = run_fxm(fxm, &[], false);
1997        assert!(!out.accepted);
1998        assert_eq!(out.lm.status, LmStatus::UserRequestedStop);
1999        assert_eq!(out.lm.iterations, 1);
2000        assert_eq!(fxm.borrow().callbacks, 2, "iterates 0 and 1");
2001        assert!(fxm.borrow().finalized.is_empty());
2002    }
2003
2004    /// The scaled test is the NLP arm's: a row's violation is judged after
2005    /// its `s_i`, and the unscaled violation separately against
2006    /// `constr_viol_tol`. A NaN never passes.
2007    #[test]
2008    fn feasibility_test_applies_both_halves_and_rejects_nan() {
2009        let t = FeasibilityTest {
2010            row_scale: vec![1e-3, 1.0],
2011            tol: 1e-8,
2012            constr_viol_tol: 1e-4,
2013        };
2014        assert!(t.passes(&[5e-6, 1e-9]), "5e-6 · 1e-3 = 5e-9 ≤ tol");
2015        assert!(
2016            !t.passes(&[5e-4, 0.0]),
2017            "scaled passes but unscaled 5e-4 > constr_viol_tol"
2018        );
2019        assert!(!t.passes(&[0.0, 2e-8]), "unscaled row over tol");
2020        assert!(!t.passes(&[Number::NAN, 0.0]));
2021    }
2022}