Skip to main content

sqp_craft_solver/
lib.rs

1//! Native SQP driver: dense damped BFGS, convex QP subproblems behind
2//! [`QpBackend`], and an L1 merit line search with per-constraint penalties.
3//!
4//! A bounded research pilot, not a general solver: at most 256 variables and
5//! local Gauss–Newton restoration for inconsistent linearizations (disable it
6//! with `max_restoration_steps = 0`). Opt-in options add elastic step QPs, a
7//! second-order correction, and these mechanisms taken from SNOPT
8//! ([`docs/snopt-lessons.md`](https://github.com/dietmarwo/SQP-craft/blob/main/docs/snopt-lessons.md)):
9//! - SNOPT's BFGS repair ([`HessianUpdate::Augmented`]);
10//! - an augmented-Lagrangian merit ([`Merit::AugmentedLagrangian`]);
11//! - a violation limit;
12//! - linear rows kept feasible ([`ProblemSpec::with_linear_rows`]);
13//! - scaling ([`Scaling`]).
14//!
15//! Convergence means the first-order KKT residuals at the returned point are
16//! within the declared tolerances, in original units; it is not a global or
17//! second-order optimality certificate.
18//!
19//! Each iteration at `x_k`:
20//!
21//! 1. Solve the step QP `min ½pᵀBp + gᵀp` with bounds shifted to step
22//!    coordinates, and accept it only through [`QpModel::accepts`].
23//! 2. Compute KKT residuals at `x_k` with the QP's multipliers; stop with
24//!    [`Status::Converged`] if all four are within tolerance.
25//! 3. Update the penalty weights by Powell's rule
26//!    `μ_i ← max(t_i, ½(μ_i + t_i))` with `t_i = 1.1·|λ_i|`, and backtrack on the merit
27//!    `φ(x) = f(x) + Σ μ_i · violation_i(c(x))` with an Armijo test against the
28//!    predicted decrease of the linearized merit.
29//! 4. At the accepted point call `derivatives`, then update `B` by damped
30//!    BFGS on the Lagrangian gradient with the new multipliers at both ends.
31#![forbid(unsafe_code)]
32#![warn(missing_docs)]
33
34pub mod batch;
35mod bfgs;
36mod linear;
37mod merit;
38mod restoration;
39mod scaling;
40
41use scaling::Scales;
42pub use scaling::Scaling;
43
44use std::time::Instant;
45
46use nalgebra::DMatrix;
47use sqp_craft::kkt::nlp_residuals;
48use sqp_craft::qp::{QpBackend, QpModel, QpOptions, QpOutcome, QpSolution, QpWarmStart};
49use sqp_craft::{
50    Bounds, CancellationToken, Control, CsrMatrix, CsrPattern, CurvatureUpdate, Error, Evaluation,
51    EvaluationCounts, IterationRecord, Multipliers, NlpProblem, Observer, Options, ProblemSpec,
52    Progress, Result, SolveResult, SqpSolver, Status,
53};
54
55/// Largest number of variables the dense pilot accepts.
56pub const DENSE_SIZE_CAP: usize = 256;
57/// Largest number of QP models [`SqpOptions::record_qp_limit`] may keep.
58pub const QP_TRACE_CAP: usize = 16;
59/// Armijo sufficient-decrease constant.
60const ARMIJO: f64 = 1e-4;
61/// Penalty weights target `PENALTY_MARGIN · |λ_i|`. The margin above `|λ_i|`
62/// keeps the predicted merit decrease positive at infeasible iterates although
63/// interior-point QP solutions stop slightly short of active bounds.
64const PENALTY_MARGIN: f64 = 1.1;
65/// The rounding level of a merit value, in units of `ε` times the size of
66/// its terms (see the augmented-Lagrangian noise steps).
67const MERIT_ROUNDING: f64 = 100.0;
68/// Consecutive steps allowed whose predicted merit decrease is non-positive
69/// only within the QP's accuracy (see the line search).
70const MAX_NOISE_STEPS: u32 = 3;
71/// Times elastic mode multiplies its weight by ten at a stationary point of
72/// the ℓ1 penalty with violated constraints before it reports
73/// `locally_infeasible`.
74const MAX_ELASTIC_INCREASES: u64 = 6;
75
76/// How the BFGS update treats a step with too little curvature,
77/// `sᵀy < 0.2·sᵀBs`.
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub enum HessianUpdate {
80    /// Powell's damping: blend `y` with `Bs` until the curvature is
81    /// `0.2·sᵀBs` (default).
82    #[default]
83    Damped,
84    /// SNOPT's second modification (Gill, Murray and Saunders 2005, §2.10):
85    /// add the gradient change of the augmented modified Lagrangian,
86    /// `(J(x⁺) − J(x))ᵀ Ω d_L` with the departure from linearity
87    /// `d_L = c(x⁺) − c(x) − J(x)s` and minimum-norm weights `Ω ≥ 0`, so
88    /// that the curvature becomes `0.2·sᵀBs`. If no such weights exist, or
89    /// the change exceeds `100·‖Bs‖`, the update is damped as with
90    /// [`Self::Damped`]. SNOPT skips the update instead; here that left a
91    /// `B` that overestimated the curvature unchanged for hundreds of steps
92    /// on complementarity problems.
93    Augmented,
94}
95
96impl HessianUpdate {
97    /// Stable lowercase name, as used by the Python binding.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            HessianUpdate::Damped => "damped",
101            HessianUpdate::Augmented => "augmented",
102        }
103    }
104
105    /// Parses [`Self::as_str`].
106    pub fn parse(name: &str) -> Result<Self> {
107        match name {
108            "damped" => Ok(HessianUpdate::Damped),
109            "augmented" => Ok(HessianUpdate::Augmented),
110            _ => Err(Error::InvalidInput(format!(
111                "hessian_update must be \"damped\" or \"augmented\", got {name:?}"
112            ))),
113        }
114    }
115}
116
117/// Merit function of the line search.
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119pub enum Merit {
120    /// `f + Σ μᵢ·violationᵢ` with Powell's per-row penalties
121    /// `μᵢ ← max(1.1|λᵢ|, ½(μᵢ + 1.1|λᵢ|))` (default).
122    #[default]
123    L1,
124    /// SNOPT's augmented Lagrangian `f − πᵀ(c − s) + ½Σρᵢ(cᵢ − sᵢ)²`,
125    /// searched jointly in the variables, the multiplier estimate `π` and
126    /// the slacks `s` (Gill, Murray and Saunders 2005, §2.7). The penalties
127    /// `ρ` are the smallest for which the linear model of the merit falls by
128    /// `½pᵀBp` over the unit step, twice the paper's slope rule, and may
129    /// decrease under its damping rule. It is differentiable, so it
130    /// needs no second-order correction; the combination is rejected.
131    /// Steps whose elastic QP leaves rows violated use the ℓ1 merit.
132    AugmentedLagrangian,
133}
134
135impl Merit {
136    /// Stable lowercase name, as used by the Python binding.
137    pub fn as_str(self) -> &'static str {
138        match self {
139            Merit::L1 => "l1",
140            Merit::AugmentedLagrangian => "augmented-lagrangian",
141        }
142    }
143
144    /// Parses [`Self::as_str`].
145    pub fn parse(name: &str) -> Result<Self> {
146        match name {
147            "l1" => Ok(Merit::L1),
148            "augmented-lagrangian" => Ok(Merit::AugmentedLagrangian),
149            _ => Err(Error::InvalidInput(format!(
150                "merit must be \"l1\" or \"augmented-lagrangian\", got {name:?}"
151            ))),
152        }
153    }
154}
155
156/// Settings of [`NativeSqp`]. Every field is used; invalid values are rejected.
157#[derive(Clone, Debug, PartialEq)]
158pub struct SqpOptions {
159    /// Maximum number of accepted steps.
160    pub max_iterations: u64,
161    /// Maximum step halvings in one line search after the first trial.
162    pub max_backtracks: u32,
163    /// Maximum `functions` plus `derivatives` calls, including rejected and
164    /// undefined trials.
165    pub max_callback_calls: u64,
166    /// Cooperative wall-time budget for the whole solve, in seconds.
167    pub time_limit_seconds: Option<f64>,
168    /// Absolute stationarity tolerance `‖∇f − Jᵀλ − z‖∞`.
169    pub stationarity_tolerance: f64,
170    /// Absolute complementarity tolerance.
171    pub complementarity_tolerance: f64,
172    /// Absolute tolerance on multipliers whose sign selects an infinite bound.
173    pub dual_sign_tolerance: f64,
174    /// Variables allowed for this solve, `1..=DENSE_SIZE_CAP`.
175    pub dense_size_limit: usize,
176    /// Initial and reset Hessian approximation `B = scale · I`.
177    pub initial_hessian_scale: f64,
178    /// Contiguous curvature block width; zero keeps full BFGS (default).
179    pub hessian_block_size: usize,
180    /// Total Hessian resets allowed (failed BFGS updates and non-descent retries).
181    pub max_hessian_resets: u32,
182    /// Native tolerance passed to the QP backend.
183    pub qp_tolerance: f64,
184    /// Iteration cap per QP.
185    pub qp_max_iterations: u32,
186    /// Opt in to compatible QP workspace reuse (not a primal/dual warm start).
187    pub qp_reuse_workspace: bool,
188    /// Pass the working set of the last step QP to the next one. Only
189    /// backends that report a working set (active-set methods) get one, so
190    /// this has no effect with Clarabel.
191    pub qp_warm_start: bool,
192    /// Solve every step QP as an ℓ1-elastic QP (`QpOptions::elastic_weight`):
193    /// constraint rows may be violated at a cost, so the QP is always
194    /// feasible and feasibility restoration is not used. Requires a backend
195    /// with elastic support (`ActiveSetQp`).
196    pub elastic_mode: bool,
197    /// Initial elastic weight, relative to `max(1, ‖∇f(x₀)‖∞)`.
198    pub elastic_weight: f64,
199    /// When the full step fails the merit test and increases the constraint
200    /// violation, solve the step QP once more with the constraints shifted by
201    /// their second-order change along the step, and try that step before
202    /// backtracking (against the Maratos effect).
203    pub second_order_correction: bool,
204    /// What to do when the curvature of a step is too small for a plain
205    /// BFGS update (`sᵀy < 0.2·sᵀBs`).
206    pub hessian_update: HessianUpdate,
207    /// Merit function of the line search.
208    pub merit: Merit,
209    /// Scaling of the QP subproblems and the BFGS matrix. The merit, the line
210    /// search and the convergence test stay in original units.
211    pub scaling: Scaling,
212    /// SNOPT's violation limit `τ_V ≥ 1`: a trial point whose violation of
213    /// some row exceeds `τ_V · max(1, violation of that row at the start)`
214    /// is rejected before its derivatives are evaluated. `None` (default)
215    /// imposes no limit.
216    pub violation_limit: Option<f64>,
217    /// Number of step QPs to keep in the report, `0..=QP_TRACE_CAP`.
218    pub record_qp_limit: usize,
219    /// Keep one [`IterationRecord`] per iteration in the report.
220    pub record_history: bool,
221    /// Accepted restoration steps allowed across the solve; zero disables it.
222    pub max_restoration_steps: u64,
223    /// Infinity-norm trust bound on each restoration direction, in x units.
224    pub restoration_step_limit: f64,
225    /// Positive diagonal regularization of the restoration Gauss–Newton model.
226    pub restoration_regularization: f64,
227}
228
229impl Default for SqpOptions {
230    fn default() -> Self {
231        Self {
232            max_iterations: 100,
233            max_backtracks: 25,
234            max_callback_calls: 2000,
235            time_limit_seconds: None,
236            stationarity_tolerance: 1e-6,
237            complementarity_tolerance: 1e-6,
238            dual_sign_tolerance: 1e-8,
239            dense_size_limit: DENSE_SIZE_CAP,
240            initial_hessian_scale: 1.0,
241            hessian_block_size: 0,
242            max_hessian_resets: 3,
243            qp_tolerance: 1e-10,
244            qp_max_iterations: 200,
245            qp_reuse_workspace: false,
246            qp_warm_start: true,
247            elastic_mode: false,
248            elastic_weight: 1e4,
249            second_order_correction: false,
250            hessian_update: HessianUpdate::Damped,
251            merit: Merit::L1,
252            scaling: Scaling::None,
253            violation_limit: None,
254            record_qp_limit: 0,
255            record_history: false,
256            max_restoration_steps: 10,
257            restoration_step_limit: 1.0,
258            restoration_regularization: 1e-4,
259        }
260    }
261}
262
263impl SqpOptions {
264    /// Rejects non-positive or non-finite tolerances and out-of-range caps.
265    pub fn validate(&self) -> Result<()> {
266        let positive = [
267            ("stationarity_tolerance", self.stationarity_tolerance),
268            ("complementarity_tolerance", self.complementarity_tolerance),
269            ("dual_sign_tolerance", self.dual_sign_tolerance),
270            ("initial_hessian_scale", self.initial_hessian_scale),
271            ("qp_tolerance", self.qp_tolerance),
272            ("elastic_weight", self.elastic_weight),
273            ("restoration_step_limit", self.restoration_step_limit),
274            (
275                "restoration_regularization",
276                self.restoration_regularization,
277            ),
278        ];
279        for (name, value) in positive {
280            if !(value.is_finite() && value > 0.0) {
281                return Err(Error::InvalidInput(format!(
282                    "{name} must be positive and finite, got {value}"
283                )));
284            }
285        }
286        if let Some(s) = self.time_limit_seconds {
287            if !(s.is_finite() && s >= 0.0) {
288                return Err(Error::InvalidInput(format!(
289                    "time_limit_seconds must be finite and nonnegative, got {s}"
290                )));
291            }
292        }
293        if !(1..=DENSE_SIZE_CAP).contains(&self.dense_size_limit) {
294            return Err(Error::InvalidInput(format!(
295                "dense_size_limit must be in 1..={DENSE_SIZE_CAP}, got {}",
296                self.dense_size_limit
297            )));
298        }
299        if self.hessian_block_size > DENSE_SIZE_CAP {
300            return Err(Error::InvalidInput(
301                "hessian_block_size must lie in 0..=256".into(),
302            ));
303        }
304        if let Some(limit) = self.violation_limit {
305            if !(limit.is_finite() && limit >= 1.0) {
306                return Err(Error::InvalidInput(format!(
307                    "violation_limit must be finite and at least 1, got {limit}"
308                )));
309            }
310        }
311        if self.second_order_correction && self.merit == Merit::AugmentedLagrangian {
312            return Err(Error::InvalidInput(
313                "second_order_correction applies to the l1 merit; the augmented-Lagrangian \
314                 merit is differentiable and needs no correction"
315                    .into(),
316            ));
317        }
318        if self.record_qp_limit > QP_TRACE_CAP {
319            return Err(Error::InvalidInput(format!(
320                "record_qp_limit must be at most {QP_TRACE_CAP}, got {}",
321                self.record_qp_limit
322            )));
323        }
324        Ok(())
325    }
326}
327
328/// The native SQP solver with QP backend `B`.
329#[derive(Clone, Debug, Default)]
330pub struct NativeSqp<B> {
331    /// QP subproblem backend.
332    pub qp: B,
333    /// Driver settings.
334    pub settings: SqpOptions,
335}
336
337fn finite(v: f64, what: &str) -> Result<f64> {
338    if v.is_finite() {
339        Ok(v)
340    } else {
341        Err(Error::Evaluation(format!("{what} became nonfinite")))
342    }
343}
344
345fn dot(a: &[f64], b: &[f64]) -> Result<f64> {
346    let mut v = 0.0;
347    for (&a, &b) in a.iter().zip(b) {
348        v = finite(v + finite(a * b, "dot product")?, "dot product")?;
349    }
350    Ok(v)
351}
352
353fn norm_inf(v: &[f64]) -> f64 {
354    v.iter().fold(0.0_f64, |a, b| a.max(b.abs()))
355}
356
357fn record_qp_work(run: &mut sqp_craft::RunReport, qp: &sqp_craft::qp::QpReport) {
358    run.qp_setups += qp.setups;
359    run.qp_updates += qp.updates;
360    run.qp_cold_retries += qp.cold_retries;
361    if qp.retry_reason.is_some() {
362        run.qp_retry_reason.clone_from(&qp.retry_reason);
363    }
364    run.qp_conversion_seconds += qp.conversion_seconds;
365    run.qp_setup_seconds += qp.setup_seconds;
366    run.qp_update_seconds += qp.update_seconds;
367    run.qp_solve_seconds += qp.solve_seconds;
368    run.qp_check_seconds += qp.check_seconds;
369}
370
371/// `Σ weights_i · (max(0, lower_i − v_i) + max(0, v_i − upper_i))` over finite sides.
372fn weighted_violation(bounds: &Bounds, values: &[f64], weights: &[f64]) -> Result<f64> {
373    let mut total = 0.0;
374    for (i, &v) in values.iter().enumerate() {
375        let mut row = 0.0;
376        if bounds.lower[i].is_finite() {
377            row += finite(bounds.lower[i] - v, "merit violation")?.max(0.0);
378        }
379        if bounds.upper[i].is_finite() {
380            row += finite(v - bounds.upper[i], "merit violation")?.max(0.0);
381        }
382        total = finite(
383            total + finite(weights[i] * row, "merit violation")?,
384            "merit",
385        )?;
386    }
387    Ok(total)
388}
389
390/// Violation of row `i` by `v`, zero inside the bounds.
391fn row_violation(bounds: &Bounds, i: usize, v: f64) -> f64 {
392    (bounds.lower[i] - v).max(v - bounds.upper[i]).max(0.0)
393}
394
395/// Whether some row of `values` violates its bounds by more than its limit.
396fn beyond_limits(bounds: &Bounds, values: &[f64], limits: &[f64]) -> bool {
397    values
398        .iter()
399        .zip(limits)
400        .enumerate()
401        .any(|(i, (&v, &limit))| row_violation(bounds, i, v) > limit)
402}
403
404/// `pᵀBp`.
405fn curvature_along(b: &DMatrix<f64>, p: &[f64]) -> Result<f64> {
406    let pv = nalgebra::DVectorView::from_slice(p, p.len());
407    finite((b * pv).dot(&pv), "step curvature")
408}
409
410/// Model calls with budget, cancellation and timing bookkeeping.
411struct Calls<'a> {
412    began: Instant,
413    settings: &'a SqpOptions,
414    cancel: &'a CancellationToken,
415    counts: EvaluationCounts,
416    seconds: f64,
417    gradient_scratch: Vec<f64>,
418    jacobian_scratch: Vec<f64>,
419}
420
421impl Calls<'_> {
422    fn recycle(&mut self, e: Evaluation) {
423        self.gradient_scratch = e.gradient;
424        self.jacobian_scratch = e.jacobian_values;
425    }
426    fn stop(&self) -> Option<Status> {
427        if self.cancel.is_cancelled() {
428            Some(Status::Cancelled)
429        } else if self
430            .settings
431            .time_limit_seconds
432            .is_some_and(|s| self.began.elapsed().as_secs_f64() >= s)
433        {
434            Some(Status::TimeLimit)
435        } else {
436            None
437        }
438    }
439
440    fn before_call(&self) -> Option<Status> {
441        self.stop().or_else(|| {
442            (self.counts.functions + self.counts.derivatives >= self.settings.max_callback_calls)
443                .then_some(Status::EvaluationLimit)
444        })
445    }
446
447    fn functions(
448        &mut self,
449        spec: &ProblemSpec,
450        model: &mut dyn NlpProblem,
451        x: &[f64],
452    ) -> Result<(f64, Vec<f64>)> {
453        spec.check_point(x, "callback point")?;
454        let mut c = vec![f64::NAN; spec.m()];
455        self.counts.functions += 1;
456        let t = Instant::now();
457        let f = model.functions(x, &mut c);
458        self.seconds += t.elapsed().as_secs_f64();
459        let f = finite(f?, "objective")?;
460        for &v in &c {
461            finite(v, "constraint value")?;
462        }
463        Ok((f, c))
464    }
465
466    fn derivatives(
467        &mut self,
468        spec: &ProblemSpec,
469        model: &mut dyn NlpProblem,
470        x: &[f64],
471        (f, c): (f64, Vec<f64>),
472        tolerance: f64,
473    ) -> Result<Evaluation> {
474        spec.check_point(x, "callback point")?;
475        let mut g = std::mem::take(&mut self.gradient_scratch);
476        let mut j = std::mem::take(&mut self.jacobian_scratch);
477        g.resize(spec.n(), f64::NAN);
478        g.fill(f64::NAN);
479        j.resize(spec.nnz(), f64::NAN);
480        j.fill(f64::NAN);
481        self.counts.derivatives += 1;
482        let t = Instant::now();
483        let result = model.derivatives(x, &mut g, &mut j);
484        self.seconds += t.elapsed().as_secs_f64();
485        if let Err(err) = result {
486            self.gradient_scratch = g;
487            self.jacobian_scratch = j;
488            return Err(err);
489        }
490        for &v in g.iter().chain(&j) {
491            finite(v, "derivative value")?;
492        }
493        let primal = spec
494            .variables()
495            .checked_violation(x)?
496            .max(spec.constraints().checked_violation(&c)?);
497        Ok(Evaluation {
498            objective: f,
499            constraints: c,
500            gradient: g,
501            jacobian_values: j,
502            primal_violation: primal,
503            feasible_candidate: primal <= tolerance,
504        })
505    }
506}
507
508/// Bounds shifted to step coordinates: `bound − value` on finite sides.
509fn shifted(bounds: &Bounds, values: &[f64]) -> Result<Bounds> {
510    let shift = |side: &[f64]| -> Result<Vec<f64>> {
511        side.iter()
512            .zip(values)
513            .map(|(&b, &v)| {
514                if b.is_finite() {
515                    finite(b - v, "shifted bound")
516                } else {
517                    Ok(b)
518                }
519            })
520            .collect()
521    };
522    Ok(Bounds::new(shift(&bounds.lower)?, shift(&bounds.upper)?))
523}
524
525/// Moves step components that exceed their QP step bounds by at most
526/// `tolerance` (in original units: `tolerance / scale_j` for a step in
527/// coordinates scaled by `scale`) onto the bound, counting each move.
528///
529/// Interior-point solutions satisfy bounds only to their tolerance. Near an
530/// active variable bound the remaining room `u − x` can be far smaller than
531/// that tolerance (it was 3e-14 in the case that exposed this), so an
532/// overshoot of 1e-11 would cut the step length for every variable to almost
533/// zero. Larger violations are left for the acceptance check to reject.
534fn snap_step(
535    step: &mut [f64],
536    step_bounds: &Bounds,
537    variables: &Bounds,
538    tolerance: f64,
539    scale: &[f64],
540    report: &mut sqp_craft::RunReport,
541) {
542    for (j, p) in step.iter_mut().enumerate() {
543        let (lo, up) = (step_bounds.lower[j], step_bounds.upper[j]);
544        let tolerance = tolerance / scale[j];
545        let target = if *p < lo && lo - *p <= tolerance {
546            lo
547        } else if *p > up && *p - up <= tolerance {
548            up
549        } else {
550            continue;
551        };
552        *p = target;
553        if variables.lower[j] == variables.upper[j] {
554            report.fixed_step_corrections += 1;
555        } else {
556            report.bound_step_corrections += 1;
557        }
558    }
559}
560
561/// Largest `α ≤ 1` with `x + αp` inside the variable box.
562fn box_step_limit(bounds: &Bounds, x: &[f64], p: &[f64]) -> f64 {
563    let mut alpha = 1.0_f64;
564    for j in 0..x.len() {
565        if p[j] > 0.0 && bounds.upper[j].is_finite() {
566            alpha = alpha.min((bounds.upper[j] - x[j]) / p[j]);
567        }
568        if p[j] < 0.0 && bounds.lower[j].is_finite() {
569            alpha = alpha.min((bounds.lower[j] - x[j]) / p[j]);
570        }
571    }
572    alpha
573}
574
575/// `x + αp`, where a component that floating-point rounding of this sum
576/// would place beyond a bound (by at most a few ulps) is set to the bound.
577/// A step that lands exactly on a bound, `p_j = u_j − x_j` with `α = 1`, can
578/// otherwise round one ulp outside and fail the box contract.
579fn trial_point(bounds: &Bounds, x: &[f64], p: &[f64], alpha: f64) -> Vec<f64> {
580    x.iter()
581        .zip(p)
582        .enumerate()
583        .map(|(j, (&x, &p))| {
584            let t = x + alpha * p;
585            let rounding = 4.0 * f64::EPSILON * (x.abs() + (alpha * p).abs());
586            let (lo, up) = (bounds.lower[j], bounds.upper[j]);
587            if t > up && t - up <= rounding {
588                up
589            } else if t < lo && lo - t <= rounding {
590                lo
591            } else {
592                t
593            }
594        })
595        .collect()
596}
597
598/// The step QP at `x` with model values `e` and Hessian approximation `b`.
599#[cfg(test)]
600fn qp_model(spec: &ProblemSpec, x: &[f64], e: &Evaluation, b: &DMatrix<f64>) -> Result<QpModel> {
601    qp_model_with_blocks(spec, x, e, b, 0)
602}
603fn qp_model_with_blocks(
604    spec: &ProblemSpec,
605    x: &[f64],
606    e: &Evaluation,
607    b: &DMatrix<f64>,
608    block: usize,
609) -> Result<QpModel> {
610    let n = spec.n();
611    let mut indptr = vec![0];
612    let capacity = n * (if block == 0 { n } else { block.min(n) } + 1) / 2;
613    let mut indices = Vec::with_capacity(capacity);
614    let mut values = Vec::with_capacity(capacity);
615    for i in 0..n {
616        let first = i.checked_div(block).unwrap_or(0) * block;
617        for j in first..=i {
618            indices.push(j);
619            values.push(b[(i, j)]);
620        }
621        indptr.push(indices.len());
622    }
623    Ok(QpModel {
624        hessian_lower: CsrMatrix {
625            pattern: CsrPattern {
626                rows: n,
627                cols: n,
628                indptr,
629                indices,
630            },
631            values,
632        },
633        gradient: e.gradient.clone(),
634        variables: shifted(spec.variables(), x)?,
635        matrix: CsrMatrix {
636            pattern: spec.jacobian().clone(),
637            values: e.jacobian_values.clone(),
638        },
639        rows: shifted(spec.constraints(), &e.constraints)?,
640    })
641}
642
643/// Refresh numeric data in a per-solve, fixed-structure QP template.
644fn refresh_qp(
645    q: &mut QpModel,
646    spec: &ProblemSpec,
647    x: &[f64],
648    e: &Evaluation,
649    b: &DMatrix<f64>,
650) -> Result<()> {
651    let h = &mut q.hessian_lower;
652    for i in 0..spec.n() {
653        for k in h.pattern.indptr[i]..h.pattern.indptr[i + 1] {
654            h.values[k] = b[(i, h.pattern.indices[k])];
655        }
656    }
657    q.gradient.clone_from(&e.gradient);
658    q.matrix.values.clone_from(&e.jacobian_values);
659    for (dst, src, values) in [
660        (&mut q.variables, spec.variables(), x),
661        (&mut q.rows, spec.constraints(), e.constraints.as_slice()),
662    ] {
663        for (out, input) in [(&mut dst.lower, &src.lower), (&mut dst.upper, &src.upper)] {
664            for (i, v) in out.iter_mut().enumerate() {
665                *v = if input[i].is_finite() {
666                    finite(input[i] - values[i], "shifted bound")?
667                } else {
668                    input[i]
669                };
670            }
671        }
672    }
673    Ok(())
674}
675
676/// `J(x⁺) − J(x)` in pattern order and the departure from linearity
677/// `d_L = c(x⁺) − c(x) − J(x)s` for [`bfgs::Repair`].
678fn curvature_repair(
679    spec: &ProblemSpec,
680    e: &Evaluation,
681    next: &Evaluation,
682    s: &[f64],
683) -> Result<(Vec<f64>, Vec<f64>)> {
684    let change = next
685        .jacobian_values
686        .iter()
687        .zip(&e.jacobian_values)
688        .map(|(&a, &b)| finite(a - b, "Jacobian change"))
689        .collect::<Result<Vec<f64>>>()?;
690    let mut departure = vec![0.0; spec.m()];
691    spec.jacobian()
692        .multiply(&e.jacobian_values, s, &mut departure);
693    for (d, (&after, &before)) in departure
694        .iter_mut()
695        .zip(next.constraints.iter().zip(&e.constraints))
696    {
697        *d = finite(after - before - *d, "departure from linearity")?;
698    }
699    Ok((change, departure))
700}
701
702/// `∇f − Jᵀλ − z`.
703fn lagrangian_gradient(spec: &ProblemSpec, e: &Evaluation, m: &Multipliers) -> Result<Vec<f64>> {
704    let mut jt = vec![0.0; spec.n()];
705    spec.jacobian()
706        .multiply_transpose(&e.jacobian_values, &m.constraints, &mut jt);
707    e.gradient
708        .iter()
709        .zip(jt)
710        .zip(&m.variables)
711        .map(|((&g, j), &z)| finite(g - j - z, "Lagrangian gradient"))
712        .collect()
713}
714
715/// Stores KKT residuals of the current candidate for multipliers `m`.
716///
717/// Any multiplier vector gives an upper bound on the best first-order residual
718/// at the point, so this is meaningful even when `m` comes from the QP solved
719/// at the previous iterate.
720/// Rounding levels of the stationarity and complementarity residuals at `x`
721/// with multipliers `m`: what floating-point evaluation of
722/// `∇f − Jᵀλ − z` and of `|μ|·distance` can contribute on its own. Entry `j`
723/// of the stationarity sums `k_j + 2` terms (`k_j` rows use variable `j`),
724/// so its error is at most about `(k_j + 2) ε (|∇f_j| + Σ_i |J_ij λ_i| + |z_j|)`;
725/// a distance `v − b` is exact to `ε (|v| + |b|)`.
726///
727/// A residual within its tolerance only by less than this level is not
728/// evidence of a first-order point: with multipliers of 5·10⁹ (an
729/// interior-point QP on a degenerate problem) the stationarity residual is
730/// pure rounding at 10⁻⁶.
731fn rounding_levels(spec: &ProblemSpec, x: &[f64], e: &Evaluation, m: &Multipliers) -> (f64, f64) {
732    let n = spec.n();
733    let mut size: Vec<f64> = (0..n)
734        .map(|j| e.gradient[j].abs() + m.variables[j].abs())
735        .collect();
736    let mut terms = vec![2.0_f64; n];
737    let jacobian = spec.jacobian();
738    for (i, &lambda) in m.constraints.iter().enumerate() {
739        for a in jacobian.indptr[i]..jacobian.indptr[i + 1] {
740            let j = jacobian.indices[a];
741            size[j] += (e.jacobian_values[a] * lambda).abs();
742            terms[j] += 1.0;
743        }
744    }
745    let stationarity = size
746        .iter()
747        .zip(&terms)
748        .fold(0.0_f64, |a, (s, t)| a.max(t * f64::EPSILON * s));
749    let mut complementarity = 0.0_f64;
750    for (values, bounds, mu) in [
751        (&e.constraints[..], spec.constraints(), &m.constraints[..]),
752        (x, spec.variables(), &m.variables[..]),
753    ] {
754        for (i, (&v, &mu)) in values.iter().zip(mu).enumerate() {
755            let bound = if mu > 0.0 {
756                bounds.lower[i]
757            } else {
758                bounds.upper[i]
759            };
760            if mu != 0.0 && bound.is_finite() {
761                complementarity =
762                    complementarity.max(mu.abs() * f64::EPSILON * (v.abs() + bound.abs()));
763            }
764        }
765    }
766    (stationarity, complementarity)
767}
768
769fn store_residuals(spec: &ProblemSpec, r: &mut SolveResult, m: Multipliers) -> Result<()> {
770    let (Some(x), Some(e)) = (r.candidate.as_ref(), r.evaluation.as_ref()) else {
771        return Ok(());
772    };
773    let k = nlp_residuals(spec, x, e, &m)?;
774    r.stationarity = Some(k.stationarity);
775    r.complementarity = Some(k.complementarity);
776    r.dual_sign = Some(k.dual_sign);
777    r.multipliers = Some(m);
778    Ok(())
779}
780
781fn noise_message(predicted: f64, noise: f64) -> String {
782    format!(
783        "the QP step is below the QP solution accuracy (predicted merit decrease \
784         {predicted:.3e}, QP accuracy {noise:.3e}); residuals at the returned point are reported"
785    )
786}
787
788fn status_message(status: Status) -> &'static str {
789    match status {
790        Status::Converged => "first-order KKT residuals within tolerance at the returned point",
791        Status::IterationLimit => "iteration limit reached",
792        Status::EvaluationLimit => "callback budget exhausted; returning the last complete point",
793        Status::TimeLimit => "time limit reached; returning the last complete point",
794        Status::Cancelled => "cancelled",
795        Status::LinearizationInfeasible => {
796            "the linearized constraints are inconsistent at this point; the nonlinear problem \
797             may still be feasible (restoration is disabled)"
798        }
799        Status::RestorationFailed => {
800            "local restoration failed; nonlinear infeasibility is not established"
801        }
802        Status::RestorationLimit => "feasibility restoration step budget exhausted",
803        Status::LocallyInfeasible => {
804            "elastic mode stopped at a stationary point of the l1 penalty with violated \
805             constraints at its largest weight; nonlinear infeasibility is not established"
806        }
807        Status::LinearInfeasible => {
808            "the rows declared linear and the variable bounds have no common point (the \
809             proximal-point QP is infeasible)"
810        }
811        Status::LineSearchFailure => "no acceptable step length within the backtracking limit",
812        Status::NoDescent => "QP step is not a descent direction for the merit function",
813        Status::Stalled => "QP step too small to change the iterate",
814        Status::NumericalFailure => "Hessian reset budget exhausted",
815        Status::QpFailure => "QP subproblem not solved acceptably",
816        _ => "",
817    }
818}
819
820impl<B: QpBackend> SqpSolver for NativeSqp<B> {
821    fn solve(
822        &mut self,
823        spec: &ProblemSpec,
824        model: &mut dyn NlpProblem,
825        x0: &[f64],
826        options: &Options,
827        cancel: &CancellationToken,
828        mut observer: Option<&mut dyn Observer>,
829    ) -> Result<SolveResult> {
830        let began = Instant::now();
831        options.validate()?;
832        self.settings.validate()?;
833        self.qp.set_threads(options.threads)?;
834        if self.settings.elastic_mode && !self.qp.supports_elastic() {
835            return Err(Error::Unsupported(
836                "elastic_mode needs a QP backend with elastic support, such as ActiveSetQp".into(),
837            ));
838        }
839        spec.check_point(x0, "start point")?;
840        if spec.n() > self.settings.dense_size_limit {
841            return Err(Error::Unsupported(format!(
842                "n = {} exceeds the dense pilot limit of {} variables",
843                spec.n(),
844                self.settings.dense_size_limit
845            )));
846        }
847        if !cancel.is_cancelled()
848            && !self
849                .settings
850                .time_limit_seconds
851                .is_some_and(|limit| began.elapsed().as_secs_f64() >= limit)
852        {
853            model.set_threads(options.threads)?;
854        }
855        let settings = &self.settings;
856        let qp_acceptance = options
857            .feasibility_tolerance
858            .min(settings.stationarity_tolerance)
859            .min(settings.complementarity_tolerance);
860        let mut calls = Calls {
861            began,
862            settings,
863            cancel,
864            counts: EvaluationCounts::default(),
865            seconds: 0.0,
866            gradient_scratch: Vec::new(),
867            jacobian_scratch: Vec::new(),
868        };
869        let mut r = SolveResult::empty(Status::IterationLimit, "");
870        r.report.method = "sqp".into();
871
872        // Start point: a complete evaluation or nothing.
873        let mut status = 'start: {
874            if let Some(status) = calls.before_call() {
875                break 'start Some(status);
876            }
877            let values = calls.functions(spec, model, x0)?;
878            if let Some(status) = calls.before_call() {
879                break 'start Some(status);
880            }
881            let evaluation =
882                calls.derivatives(spec, model, x0, values, options.feasibility_tolerance)?;
883            r.candidate = Some(x0.to_vec());
884            r.evaluation = Some(evaluation);
885            None
886        };
887        // Rows declared linear: their Jacobian at the start, against which
888        // every later evaluation is checked, and a start on those rows.
889        let linear_reference = r
890            .evaluation
891            .as_ref()
892            .filter(|_| spec.has_linear_rows())
893            .map(|e| e.jacobian_values.clone());
894        // Scales of the rows declared linear (constant), used from the
895        // projection on; in Jacobian mode recomputed below from every row.
896        let mut scales = match (&r.evaluation, settings.scaling) {
897            (Some(e), Scaling::Linear | Scaling::Jacobian) => {
898                Scales::of(Scaling::Linear, spec, &e.jacobian_values)
899            }
900            _ => Scales::identity(spec.n(), spec.m()),
901        };
902        if status.is_none()
903            && linear_reference.is_some()
904            && linear::violated(
905                spec,
906                r.evaluation.as_ref().expect("evaluated"),
907                options.feasibility_tolerance,
908            )
909        {
910            status = linear::project(
911                &mut self.qp,
912                spec,
913                model,
914                &mut calls,
915                &mut r,
916                options,
917                qp_acceptance,
918                &scales,
919            )?;
920            if let (Some(reference), Some(e)) = (&linear_reference, &r.evaluation) {
921                linear::check_constant(spec, reference, e)?;
922            }
923        }
924        // SNOPT's Scale option 2 uses the first point on the linear rows.
925        if let (Scaling::Jacobian, Some(e)) = (settings.scaling, &r.evaluation) {
926            scales = Scales::of(Scaling::Jacobian, spec, &e.jacobian_values);
927        }
928        let scaled_identity = scales.is_identity();
929        if settings.scaling != Scaling::None {
930            r.report.variable_scales.clone_from(&scales.variables);
931            r.report.row_scales.clone_from(&scales.rows);
932        }
933        // In elastic mode the rows declared linear stay hard.
934        let hard_rows = if settings.elastic_mode && spec.has_linear_rows() {
935            spec.linear_rows().to_vec()
936        } else {
937            Vec::new()
938        };
939
940        let n = spec.n();
941        let mut b = DMatrix::identity(n, n) * settings.initial_hessian_scale;
942        let mut effective_block = if settings.hessian_block_size >= n {
943            0
944        } else {
945            settings.hessian_block_size
946        };
947        let mut bfgs_work = bfgs::Curvature::new(n, effective_block);
948        let mut qp_work: Option<QpModel> = None;
949        // The last step QP's working set, if the backend reports one.
950        let mut qp_warm: Option<QpWarmStart> = None;
951        // Elastic mode: the weight γ, scaled by the start gradient.
952        let mut elastic_weight = r.evaluation.as_ref().and_then(|e| {
953            settings
954                .elastic_mode
955                .then(|| settings.elastic_weight * norm_inf(&e.gradient).max(1.0))
956        });
957        let mut relaxed = Bounds::free(0);
958        let mut transpose = Vec::new();
959        let mut qp_checks = sqp_craft::qp::QpResidualWorkspace::default();
960        let mut penalties = vec![0.0; spec.m()];
961        let mut have_penalties = false;
962        let mut augmented = merit::AugmentedLagrangian::new(spec.m());
963        // Rows kept out of the merit (linear rows kept feasible).
964        let skip_rows = spec.linear_rows().to_vec();
965        // SNOPT's violation limit, fixed at the start point.
966        let limits: Option<Vec<f64>> = match (settings.violation_limit, r.evaluation.as_ref()) {
967            (Some(tau), Some(e)) => Some(
968                (0..spec.m())
969                    .map(|i| tau * row_violation(spec.constraints(), i, e.constraints[i]).max(1.0))
970                    .collect(),
971            ),
972            _ => None,
973        };
974        // The last step QP left rows violated (elastic mode).
975        let mut elastic_phase = false;
976        let mut retry_descent = false;
977        let mut noise_steps = 0_u32;
978        let mut notify = true;
979        while status.is_none() {
980            if let Some(s) = calls.stop() {
981                status = Some(s);
982                break;
983            }
984            if notify {
985                if let Some(obs) = observer.as_deref_mut() {
986                    let e = r.evaluation.as_ref().expect("evaluated");
987                    let progress = Progress {
988                        iteration: r.iterations,
989                        objective: e.objective,
990                        primal_violation: e.primal_violation,
991                    };
992                    if obs.observe(&progress)? == Control::Stop {
993                        status = Some(Status::Cancelled);
994                        r.message = "stopped by the observer".into();
995                        break;
996                    }
997                }
998                if let Some(s) = calls.stop() {
999                    status = Some(s);
1000                    break;
1001                }
1002            }
1003            if r.iterations >= settings.max_iterations {
1004                status = Some(Status::IterationLimit);
1005                break;
1006            }
1007
1008            // 1. Step QP.
1009            let x = r.candidate.as_ref().expect("evaluated");
1010            let e = r.evaluation.as_ref().expect("evaluated");
1011            if let Some(q) = qp_work.as_mut() {
1012                refresh_qp(q, spec, x, e, &b)?;
1013            } else {
1014                qp_work = Some(qp_model_with_blocks(spec, x, e, &b, effective_block)?);
1015            }
1016            if !scaled_identity {
1017                // B is kept in scaled coordinates; scale the rest.
1018                scales.scale_qp(qp_work.as_mut().expect("QP initialized"), &scales.rows);
1019            }
1020            let qp = qp_work.as_ref().expect("QP initialized");
1021            if r.report.qp_trace.len() < settings.record_qp_limit {
1022                r.report.qp_trace.push((*qp).clone());
1023            }
1024            let remaining = settings
1025                .time_limit_seconds
1026                .map(|s| (s - calls.began.elapsed().as_secs_f64()).max(0.0));
1027            self.qp.configure(&QpOptions {
1028                reuse_workspace: settings.qp_reuse_workspace,
1029                tolerance: settings.qp_tolerance,
1030                acceptance_tolerance: qp_acceptance,
1031                max_iterations: settings.qp_max_iterations,
1032                time_limit_seconds: remaining,
1033                elastic_weight,
1034                hard_rows: hard_rows.clone(),
1035            })?;
1036            let t = Instant::now();
1037            r.report.qp_solves += 1;
1038            let outcome = self.qp.solve(qp, qp_warm.as_ref(), cancel);
1039            r.report.qp_seconds += t.elapsed().as_secs_f64();
1040            let report = self.qp.report();
1041            record_qp_work(&mut r.report, &report);
1042            r.report.qp_backend = report.backend;
1043            r.report.qp_status = report.raw_status;
1044            r.report.qp_iterations += report.iterations;
1045            let qp_iterations = report.iterations;
1046            if let Some(s) = calls.stop() {
1047                status = Some(s);
1048                break;
1049            }
1050            let mut solution: QpSolution = match outcome {
1051                Ok(QpOutcome::Optimal(s)) => s,
1052                Ok(QpOutcome::Infeasible) => {
1053                    if settings.max_restoration_steps == 0 {
1054                        status = Some(Status::LinearizationInfeasible);
1055                        break;
1056                    }
1057                    if r.report.restoration_steps >= settings.max_restoration_steps {
1058                        status = Some(Status::RestorationLimit);
1059                        break;
1060                    }
1061                    let began = Instant::now();
1062                    let restored = restoration::step(
1063                        &mut self.qp,
1064                        spec,
1065                        model,
1066                        &mut calls,
1067                        &mut r,
1068                        options,
1069                        &scales,
1070                    );
1071                    r.report.restoration_seconds += began.elapsed().as_secs_f64();
1072                    status = restored?;
1073                    if let (Some(reference), Some(e)) = (&linear_reference, &r.evaluation) {
1074                        linear::check_constant(spec, reference, e)?;
1075                    }
1076                    if status.is_some() {
1077                        break;
1078                    }
1079                    // Feasibility steps do not carry an NLP multiplier estimate.
1080                    // Restart curvature/penalties without spending the BFGS failure budget.
1081                    b = DMatrix::identity(n, n) * settings.initial_hessian_scale;
1082                    penalties.fill(0.0);
1083                    have_penalties = false;
1084                    augmented.reset();
1085                    retry_descent = false;
1086                    noise_steps = 0;
1087                    notify = true;
1088                    continue;
1089                }
1090                Ok(QpOutcome::Cancelled) => {
1091                    status = Some(Status::Cancelled);
1092                    break;
1093                }
1094                Ok(QpOutcome::LimitReached(_)) => {
1095                    let reason = report.limit_reason.unwrap_or_else(|| "unknown".into());
1096                    status = Some(if reason == "time" {
1097                        Status::TimeLimit
1098                    } else {
1099                        Status::QpFailure
1100                    });
1101                    r.message = format!("QP subproblem stopped at its {reason} limit");
1102                    break;
1103                }
1104                Ok(QpOutcome::Unbounded) => {
1105                    status = Some(Status::QpFailure);
1106                    r.message = "QP subproblem reported unbounded although B is positive \
1107                                 definite; numerical trouble"
1108                        .into();
1109                    break;
1110                }
1111                Ok(QpOutcome::NotPositiveDefinite) => {
1112                    r.report.qp_not_positive_definite += 1;
1113                    // Damping keeps B positive definite in exact arithmetic,
1114                    // but rounding in the explicit update can leave it
1115                    // indefinite. The backend's exact check is the only
1116                    // positive-definiteness test of B: treat its rejection
1117                    // like a failed update (reset, or stop without budget)
1118                    // and re-solve this QP without counting an iteration.
1119                    if r.report.hessian_resets >= u64::from(settings.max_hessian_resets) {
1120                        status = Some(Status::NumericalFailure);
1121                        r.message = "the Hessian approximation is not positive definite and \
1122                                     the Hessian reset budget is exhausted"
1123                            .into();
1124                        break;
1125                    }
1126                    if effective_block > 0 {
1127                        effective_block = 0;
1128                        bfgs_work = bfgs::Curvature::new(n, 0);
1129                        qp_work = None;
1130                        r.report.block_hessian_fallbacks += 1;
1131                    }
1132                    b = DMatrix::identity(n, n) * settings.initial_hessian_scale;
1133                    r.report.hessian_resets += 1;
1134                    retry_descent = false;
1135                    notify = false;
1136                    continue;
1137                }
1138                Ok(other) => {
1139                    status = Some(Status::QpFailure);
1140                    r.message = format!("unexpected QP outcome {other:?}");
1141                    break;
1142                }
1143                Err(Error::Backend(message)) => {
1144                    status = Some(Status::QpFailure);
1145                    r.message = message;
1146                    break;
1147                }
1148                Err(err) => return Err(err),
1149            };
1150            if settings.qp_warm_start {
1151                if let Some(active_set) = solution.active_set.take() {
1152                    qp_warm = Some(QpWarmStart {
1153                        active_set: Some(active_set),
1154                        ..Default::default()
1155                    });
1156                }
1157            }
1158            // Put tolerance-level overshoots of the step bounds (including the
1159            // zero-width bounds of fixed variables) on the bound; recheck below.
1160            snap_step(
1161                &mut solution.step,
1162                &qp.variables,
1163                spec.variables(),
1164                options.feasibility_tolerance,
1165                &scales.variables,
1166                &mut r.report,
1167            );
1168            let check = match elastic_weight {
1169                Some(weight) => qp.accepts_elastic_with_hard_rows(
1170                    &solution,
1171                    qp_acceptance,
1172                    weight,
1173                    &hard_rows,
1174                    &mut qp_checks,
1175                )?,
1176                None => qp.accepts_with_workspace(&solution, qp_acceptance, &mut qp_checks)?,
1177            };
1178            if !check.accepted {
1179                status = Some(Status::QpFailure);
1180                r.message = format!(
1181                    "QP solution failed the independent check (scale {:.3e}): {:?}",
1182                    check.scale, check.residuals
1183                );
1184                break;
1185            }
1186            if !scaled_identity {
1187                scales.unscale_solution(&mut solution, &scales.rows);
1188            }
1189
1190            // 2. Convergence test at x_k with the fresh multipliers.
1191            store_residuals(spec, &mut r, solution.multipliers.clone())?;
1192            let e = r.evaluation.as_ref().expect("evaluated");
1193            let (stationarity, complementarity) = (
1194                r.stationarity.expect("stored"),
1195                r.complementarity.expect("stored"),
1196            );
1197            let mut record = IterationRecord {
1198                iteration: r.iterations,
1199                objective: e.objective,
1200                primal_violation: e.primal_violation,
1201                stationarity,
1202                complementarity,
1203                qp_step_norm: norm_inf(&solution.step),
1204                step_length: None,
1205                trials: 0,
1206                max_penalty: 0.0,
1207                qp_iterations,
1208                second_order_correction: false,
1209                curvature: None,
1210            };
1211            // The residuals must clear their tolerances by their own rounding
1212            // level, or the claim rests on rounding noise.
1213            let (stationarity_level, complementarity_level) = rounding_levels(
1214                spec,
1215                r.candidate.as_ref().expect("evaluated"),
1216                e,
1217                r.multipliers.as_ref().expect("stored"),
1218            );
1219            if e.primal_violation <= options.feasibility_tolerance
1220                && stationarity + stationarity_level <= settings.stationarity_tolerance
1221                && complementarity + complementarity_level <= settings.complementarity_tolerance
1222                && r.dual_sign.expect("stored") <= settings.dual_sign_tolerance
1223            {
1224                if settings.record_history {
1225                    r.report.history.push(record);
1226                }
1227                status = Some(Status::Converged);
1228                break;
1229            }
1230            let x = r.candidate.as_ref().expect("evaluated");
1231            let p = &solution.step;
1232            let mut linear = vec![0.0; spec.m()];
1233            spec.jacobian().multiply(&e.jacobian_values, p, &mut linear);
1234            for (l, &c) in linear.iter_mut().zip(&e.constraints) {
1235                *l = finite(*l + c, "linearized constraints")?;
1236            }
1237            // Rows the elastic QP leaves violated.
1238            let rows = spec.constraints();
1239            let elastic_rows: Vec<usize> = match elastic_weight {
1240                Some(_) => (0..spec.m())
1241                    .filter(|&i| {
1242                        !spec.linear_rows()[i]
1243                            && (rows.lower[i] - linear[i] > options.feasibility_tolerance
1244                                || linear[i] - rows.upper[i] > options.feasibility_tolerance)
1245                    })
1246                    .collect(),
1247                None => Vec::new(),
1248            };
1249            if let Some(weight) = elastic_weight.filter(|_| {
1250                !elastic_rows.is_empty() && e.primal_violation > options.feasibility_tolerance
1251            }) {
1252                // At a stationary point of f + γ·violation with violated rows
1253                // a larger weight may still reach feasibility; past the last
1254                // increase the point is reported as locally infeasible.
1255                let weights: Vec<f64> = scales.rows.iter().map(|&r| weight * r).collect();
1256                let k = sqp_craft::kkt::elastic_residuals_with_row_weights(
1257                    &sqp_craft::kkt::KktPoint {
1258                        x,
1259                        variable_bounds: spec.variables(),
1260                        rows: &e.constraints,
1261                        row_bounds: rows,
1262                        gradient: &e.gradient,
1263                        jacobian: spec.jacobian(),
1264                        jacobian_values: &e.jacobian_values,
1265                    },
1266                    &solution.multipliers,
1267                    &weights,
1268                    &hard_rows,
1269                    options.feasibility_tolerance,
1270                    &mut relaxed,
1271                    &mut transpose,
1272                )?;
1273                // A negligible step says the same as small residuals: the QP
1274                // finds x stationary for the penalty.
1275                let scale = 1.0 + weight * scales.largest_row();
1276                let tiny = norm_inf(p) <= 1e-14 * (1.0 + norm_inf(x));
1277                if tiny
1278                    || k.stationarity <= settings.stationarity_tolerance
1279                        && k.complementarity <= settings.complementarity_tolerance * scale
1280                        && k.dual_sign <= settings.dual_sign_tolerance * scale
1281                {
1282                    if r.report.elastic_weight_increases < MAX_ELASTIC_INCREASES {
1283                        elastic_weight = Some(10.0 * weight);
1284                        r.report.elastic_weight_increases += 1;
1285                        retry_descent = false;
1286                        notify = false;
1287                        continue;
1288                    }
1289                    if settings.record_history {
1290                        r.report.history.push(record);
1291                    }
1292                    status = Some(Status::LocallyInfeasible);
1293                    break;
1294                }
1295            }
1296
1297            // 3. Merit line search.
1298            if elastic_phase && elastic_rows.is_empty() {
1299                // The linearization is consistent again. Penalties of γ from
1300                // the elastic phase would shorten every following step (as
1301                // after restoration, start Powell's rule afresh).
1302                have_penalties = false;
1303            }
1304            elastic_phase = !elastic_rows.is_empty();
1305            // The augmented-Lagrangian merit, unless the elastic QP left rows
1306            // violated: its step then minimizes the model of f + γ·violation.
1307            let search = if settings.merit == Merit::AugmentedLagrangian && elastic_rows.is_empty()
1308            {
1309                Some(augmented.search(
1310                    spec.constraints(),
1311                    e.objective,
1312                    &e.constraints,
1313                    &linear,
1314                    dot(&e.gradient, p)?,
1315                    curvature_along(&b, &scales.to_scaled(p))?,
1316                    &solution.multipliers.constraints,
1317                    &skip_rows,
1318                )?)
1319            } else {
1320                None
1321            };
1322            let (merit, predicted, sensitivity) = if let Some(search) = &search {
1323                record.max_penalty = search.max_penalty;
1324                (search.value, search.predicted, search.sensitivity)
1325            } else {
1326                for (mu, &lambda) in penalties.iter_mut().zip(&solution.multipliers.constraints) {
1327                    let target = PENALTY_MARGIN * lambda.abs();
1328                    *mu = if have_penalties {
1329                        target.max(0.5 * (*mu + target))
1330                    } else {
1331                        target
1332                    };
1333                    finite(*mu, "penalty")?;
1334                }
1335                have_penalties = true;
1336                // Rows declared linear stay satisfied along the step.
1337                for (mu, &linear) in penalties.iter_mut().zip(spec.linear_rows()) {
1338                    if linear {
1339                        *mu = 0.0;
1340                    }
1341                }
1342                if let Some(weight) = elastic_weight {
1343                    // The elastic QP minimizes the model of f + γ·violation, so
1344                    // its step is a descent direction for that merit: rows it
1345                    // leaves violated (|λ_i| = γ) are weighted by exactly γ.
1346                    for &i in &elastic_rows {
1347                        penalties[i] = weight * scales.rows[i];
1348                    }
1349                }
1350                record.max_penalty = norm_inf(&penalties);
1351                let violation = weighted_violation(spec.constraints(), &e.constraints, &penalties)?;
1352                let linear_violation = weighted_violation(spec.constraints(), &linear, &penalties)?;
1353                let predicted = finite(
1354                    -dot(&e.gradient, p)? + (violation - linear_violation),
1355                    "predicted decrease",
1356                )?;
1357                (
1358                    finite(e.objective + violation, "merit")?,
1359                    predicted,
1360                    record.max_penalty,
1361                )
1362            };
1363            if norm_inf(p) <= 1e-14 * (1.0 + norm_inf(x)) {
1364                if settings.record_history {
1365                    r.report.history.push(record);
1366                }
1367                status = Some(Status::Stalled);
1368                break;
1369            }
1370            // `Some(accuracy)` when this step is taken although its predicted
1371            // decrease is non-positive within the QP's accuracy.
1372            let mut noise_step = None;
1373            if predicted <= 0.0 {
1374                // First retry once from B = scale·I: a badly scaled B (for
1375                // example nearly zero curvature) can make an accurate step tiny.
1376                if !retry_descent
1377                    && r.report.hessian_resets < u64::from(settings.max_hessian_resets)
1378                {
1379                    b = DMatrix::identity(n, n) * settings.initial_hessian_scale;
1380                    r.report.hessian_resets += 1;
1381                    retry_descent = true;
1382                    notify = false;
1383                    continue;
1384                }
1385                // With exact QP solutions the predicted decrease is at least
1386                // pᵀBp > 0. An interior-point QP leaves active rows a little
1387                // short of their bounds, which costs up to the QP's
1388                // complementarity residual per entry. A non-positive value
1389                // within that accuracy means the step is at the QP's noise
1390                // level. Near a solution the step is then still the right one
1391                // (pᵀBp ~ 1e-12 while stationarity is just above tolerance),
1392                // so take it if the merit does not rise by more than that
1393                // accuracy, a bounded number of times in a row.
1394                let qp = check.residuals;
1395                // The QP residuals are in scaled units.
1396                let noise = (spec.m() + n) as f64 * qp.complementarity
1397                    + qp.stationarity
1398                        * p.iter()
1399                            .zip(&scales.variables)
1400                            .map(|(v, d)| (v / d).abs())
1401                            .sum::<f64>()
1402                    + sensitivity / scales.smallest_row() * spec.m() as f64 * qp.primal;
1403                if -predicted <= noise && noise_steps < MAX_NOISE_STEPS {
1404                    noise_step = Some(noise);
1405                } else {
1406                    if settings.record_history {
1407                        r.report.history.push(record);
1408                    }
1409                    if -predicted <= noise {
1410                        status = Some(Status::Stalled);
1411                        r.message = noise_message(predicted, noise);
1412                    } else {
1413                        status = Some(Status::NoDescent);
1414                    }
1415                    break;
1416                }
1417            }
1418            // The augmented Lagrangian changes only to second order in the
1419            // violation. Near a solution its predicted decrease can fall below
1420            // the rounding of the merit value itself: an interior-point QP
1421            // that leaves a violation of 10⁻⁶ predicts 10⁻¹⁴ with f ≈ 20. Such
1422            // a step is taken at full length if the merit does not rise by
1423            // more than that rounding level, as a noise-level step.
1424            if let (Some(search), None) = (&search, noise_step) {
1425                let rounding = MERIT_ROUNDING * f64::EPSILON * search.magnitude;
1426                if predicted <= rounding && noise_steps < MAX_NOISE_STEPS {
1427                    noise_step = Some(rounding);
1428                }
1429            }
1430            if let Some(search) = &search {
1431                augmented.commit(search);
1432            }
1433            let mut alpha = box_step_limit(spec.variables(), x, p);
1434            if !(alpha.is_finite() && alpha > 0.0) {
1435                status = Some(Status::Stalled);
1436                r.message = "the QP step leaves the variable box immediately".into();
1437                break;
1438            }
1439            let mut accepted = None;
1440            let max_backtracks = if noise_step.is_some() {
1441                0
1442            } else {
1443                settings.max_backtracks
1444            };
1445            for _ in 0..=max_backtracks {
1446                if let Some(s) = calls.before_call() {
1447                    status = Some(s);
1448                    break;
1449                }
1450                let trial = trial_point(spec.variables(), x, p, alpha);
1451                if spec.check_point(&trial, "trial").is_err() {
1452                    // Roundoff pushed the trial out of the box: shorten without a call.
1453                    alpha *= 0.5;
1454                    r.report.rejected_trials += 1;
1455                    continue;
1456                }
1457                record.trials += 1;
1458                let values = match calls.functions(spec, model, &trial) {
1459                    Ok(v) => v,
1460                    Err(Error::UndefinedPoint(_)) => {
1461                        r.report.undefined_trials += 1;
1462                        r.report.rejected_trials += 1;
1463                        alpha *= 0.5;
1464                        continue;
1465                    }
1466                    Err(err) => return Err(err),
1467                };
1468                if limits
1469                    .as_ref()
1470                    .is_some_and(|l| beyond_limits(spec.constraints(), &values.1, l))
1471                {
1472                    r.report.violation_limit_rejections += 1;
1473                    r.report.rejected_trials += 1;
1474                    alpha *= 0.5;
1475                    continue;
1476                }
1477                let trial_merit = match &search {
1478                    Some(search) => search.value_at(&augmented.pi, alpha, values.0, &values.1)?,
1479                    None => finite(
1480                        values.0 + weighted_violation(spec.constraints(), &values.1, &penalties)?,
1481                        "trial merit",
1482                    )?,
1483                };
1484                let threshold = match noise_step {
1485                    Some(noise) => merit + noise,
1486                    None => merit - ARMIJO * alpha * predicted,
1487                };
1488                if trial_merit > threshold {
1489                    r.report.rejected_trials += 1;
1490                    // Second-order correction: once, for a rejected full step
1491                    // that increased the constraint violation through the
1492                    // curvature of the constraints (for linear rows the
1493                    // corrected QP would return the same step).
1494                    let try_correction = settings.second_order_correction
1495                        && alpha == 1.0
1496                        && noise_step.is_none()
1497                        && !record.second_order_correction;
1498                    let shift: Vec<f64> = if try_correction {
1499                        values
1500                            .1
1501                            .iter()
1502                            .zip(&linear)
1503                            .zip(spec.linear_rows())
1504                            .map(|((c, l), &exact)| if exact { 0.0 } else { c - l })
1505                            .collect()
1506                    } else {
1507                        Vec::new()
1508                    };
1509                    let corrected = if try_correction
1510                        && norm_inf(&shift) > options.feasibility_tolerance
1511                        && spec.constraints().checked_violation(&values.1)? >= e.primal_violation
1512                    {
1513                        record.second_order_correction = true;
1514                        let q = qp_work.as_mut().expect("QP initialized");
1515                        let saved = q.rows.clone();
1516                        // Replace c(x) + Jp by c(x + p): the step must also
1517                        // absorb the curvature of the constraints along p.
1518                        for (i, &shift) in shift.iter().enumerate() {
1519                            let shift = finite(shift * scales.rows[i], "second-order shift")?;
1520                            q.rows.lower[i] -= shift;
1521                            q.rows.upper[i] -= shift;
1522                        }
1523                        let remaining = settings
1524                            .time_limit_seconds
1525                            .map(|s| (s - calls.began.elapsed().as_secs_f64()).max(0.0));
1526                        self.qp.configure(&QpOptions {
1527                            reuse_workspace: settings.qp_reuse_workspace,
1528                            tolerance: settings.qp_tolerance,
1529                            acceptance_tolerance: qp_acceptance,
1530                            max_iterations: settings.qp_max_iterations,
1531                            time_limit_seconds: remaining,
1532                            elastic_weight,
1533                            hard_rows: hard_rows.clone(),
1534                        })?;
1535                        let t = Instant::now();
1536                        r.report.qp_solves += 1;
1537                        let outcome = self.qp.solve(q, qp_warm.as_ref(), cancel);
1538                        r.report.qp_seconds += t.elapsed().as_secs_f64();
1539                        let report = self.qp.report();
1540                        record_qp_work(&mut r.report, &report);
1541                        r.report.qp_iterations += report.iterations;
1542                        let step = match outcome {
1543                            Ok(QpOutcome::Optimal(mut s)) => {
1544                                snap_step(
1545                                    &mut s.step,
1546                                    &q.variables,
1547                                    spec.variables(),
1548                                    options.feasibility_tolerance,
1549                                    &scales.variables,
1550                                    &mut r.report,
1551                                );
1552                                let check = match elastic_weight {
1553                                    Some(weight) => q.accepts_elastic_with_hard_rows(
1554                                        &s,
1555                                        qp_acceptance,
1556                                        weight,
1557                                        &hard_rows,
1558                                        &mut qp_checks,
1559                                    )?,
1560                                    None => {
1561                                        q.accepts_with_workspace(&s, qp_acceptance, &mut qp_checks)?
1562                                    }
1563                                };
1564                                if !scaled_identity {
1565                                    scales.unscale_solution(&mut s, &scales.rows);
1566                                }
1567                                check.accepted.then_some(s.step)
1568                            }
1569                            // No correction; a stop is noticed before the
1570                            // next call.
1571                            Ok(_) | Err(Error::Backend(_)) => None,
1572                            Err(err) => return Err(err),
1573                        };
1574                        q.rows = saved;
1575                        step
1576                    } else {
1577                        None
1578                    };
1579                    if let Some(step) = corrected {
1580                        if let Some(s) = calls.before_call() {
1581                            status = Some(s);
1582                            break;
1583                        }
1584                        let trial = trial_point(spec.variables(), x, &step, 1.0);
1585                        if spec.check_point(&trial, "trial").is_ok() {
1586                            record.trials += 1;
1587                            r.report.second_order_corrections += 1;
1588                            let values = match calls.functions(spec, model, &trial) {
1589                                Ok(v) => Some(v),
1590                                Err(Error::UndefinedPoint(_)) => {
1591                                    r.report.undefined_trials += 1;
1592                                    None
1593                                }
1594                                Err(err) => return Err(err),
1595                            };
1596                            let values = values.filter(|v| {
1597                                let beyond = limits
1598                                    .as_ref()
1599                                    .is_some_and(|l| beyond_limits(spec.constraints(), &v.1, l));
1600                                r.report.violation_limit_rejections += u64::from(beyond);
1601                                !beyond
1602                            });
1603                            let mut evaluation = None;
1604                            if let Some(values) = values {
1605                                let corrected_merit = finite(
1606                                    values.0
1607                                        + weighted_violation(
1608                                            spec.constraints(),
1609                                            &values.1,
1610                                            &penalties,
1611                                        )?,
1612                                    "corrected merit",
1613                                )?;
1614                                if corrected_merit <= threshold {
1615                                    if let Some(s) = calls.before_call() {
1616                                        status = Some(s);
1617                                        break;
1618                                    }
1619                                    match calls.derivatives(
1620                                        spec,
1621                                        model,
1622                                        &trial,
1623                                        values,
1624                                        options.feasibility_tolerance,
1625                                    ) {
1626                                        Ok(e) => evaluation = Some(e),
1627                                        Err(Error::UndefinedPoint(_)) => {
1628                                            r.report.undefined_trials += 1;
1629                                        }
1630                                        Err(err) => return Err(err),
1631                                    }
1632                                }
1633                            }
1634                            if let Some(evaluation) = evaluation {
1635                                r.report.second_order_steps += 1;
1636                                accepted = Some((trial, evaluation));
1637                                break;
1638                            }
1639                            r.report.rejected_trials += 1;
1640                        }
1641                    }
1642                    alpha *= 0.5;
1643                    continue;
1644                }
1645                if let Some(s) = calls.before_call() {
1646                    status = Some(s);
1647                    break;
1648                }
1649                match calls.derivatives(spec, model, &trial, values, options.feasibility_tolerance)
1650                {
1651                    Ok(evaluation) => {
1652                        accepted = Some((trial, evaluation));
1653                        break;
1654                    }
1655                    Err(Error::UndefinedPoint(_)) => {
1656                        r.report.undefined_trials += 1;
1657                        r.report.rejected_trials += 1;
1658                        alpha *= 0.5;
1659                    }
1660                    Err(err) => return Err(err),
1661                }
1662            }
1663            if status.is_some() {
1664                break;
1665            }
1666            if let Some(s) = calls.stop() {
1667                status = Some(s);
1668                break;
1669            }
1670            let Some((next, next_evaluation)) = accepted else {
1671                if settings.record_history {
1672                    r.report.history.push(record);
1673                }
1674                if let Some(noise) = noise_step {
1675                    status = Some(Status::Stalled);
1676                    r.message = noise_message(predicted, noise);
1677                } else {
1678                    status = Some(Status::LineSearchFailure);
1679                }
1680                break;
1681            };
1682            if !elastic_rows.is_empty() {
1683                r.report.elastic_steps += 1;
1684            }
1685            noise_steps = if noise_step.is_some() {
1686                noise_steps + 1
1687            } else {
1688                0
1689            };
1690            record.step_length = Some(alpha);
1691            if settings.merit == Merit::AugmentedLagrangian {
1692                augmented.advance(&solution.multipliers.constraints, alpha);
1693            }
1694
1695            // 4. Accept the step and update B.
1696            if let Some(reference) = &linear_reference {
1697                linear::check_constant(spec, reference, &next_evaluation)?;
1698            }
1699            let s: Vec<f64> = next.iter().zip(x).map(|(&a, &b)| a - b).collect();
1700            let old_l = lagrangian_gradient(spec, e, &solution.multipliers)?;
1701            let new_l = lagrangian_gradient(spec, &next_evaluation, &solution.multipliers)?;
1702            let y: Vec<f64> = new_l.iter().zip(old_l).map(|(&a, b)| a - b).collect();
1703            let t = Instant::now();
1704            let (mut change, mut departure) = match settings.hessian_update {
1705                HessianUpdate::Damped => (Vec::new(), Vec::new()),
1706                HessianUpdate::Augmented => curvature_repair(spec, e, &next_evaluation, &s)?,
1707            };
1708            // B lives in scaled coordinates: s̃ = s/D, ỹ = D y, J̃ = R J D.
1709            let (s_scaled, y) = if scaled_identity {
1710                (s.clone(), y)
1711            } else {
1712                let jacobian = spec.jacobian();
1713                for i in 0..spec.m() {
1714                    for a in jacobian.indptr[i]..jacobian.indptr[i + 1] {
1715                        if let Some(c) = change.get_mut(a) {
1716                            *c *= scales.rows[i] * scales.variables[jacobian.indices[a]];
1717                        }
1718                    }
1719                    if let Some(d) = departure.get_mut(i) {
1720                        *d *= scales.rows[i];
1721                    }
1722                }
1723                let y = y
1724                    .iter()
1725                    .zip(&scales.variables)
1726                    .map(|(v, d)| v * d)
1727                    .collect();
1728                (scales.to_scaled(&s), y)
1729            };
1730            let repair =
1731                (settings.hessian_update == HessianUpdate::Augmented).then(|| bfgs::Repair {
1732                    pattern: spec.jacobian(),
1733                    change: &change,
1734                    departure: &departure,
1735                });
1736            let updated = bfgs_work.update(&mut b, &s_scaled, &y, repair.as_ref());
1737            r.report.bfgs_seconds += t.elapsed().as_secs_f64();
1738            match updated {
1739                Some(CurvatureUpdate::Damped) => r.report.bfgs_damped_updates += 1,
1740                Some(CurvatureUpdate::Modified) => r.report.bfgs_modified_updates += 1,
1741                _ => {}
1742            }
1743            record.curvature = Some(updated.unwrap_or(CurvatureUpdate::Reset));
1744            if settings.record_history {
1745                r.report.history.push(record);
1746            }
1747            r.candidate = Some(next);
1748            if let Some(old) = r.evaluation.replace(next_evaluation) {
1749                calls.recycle(old);
1750            }
1751            r.iterations += 1;
1752            r.report.step_norm = Some(norm_inf(&s));
1753            store_residuals(spec, &mut r, solution.multipliers)?;
1754            if updated.is_none() {
1755                if r.report.hessian_resets >= u64::from(settings.max_hessian_resets) {
1756                    status = Some(Status::NumericalFailure);
1757                    r.message =
1758                        "BFGS update failed and the Hessian reset budget is exhausted".into();
1759                    break;
1760                }
1761                if effective_block > 0 {
1762                    effective_block = 0;
1763                    bfgs_work = bfgs::Curvature::new(n, 0);
1764                    qp_work = None;
1765                    r.report.block_hessian_fallbacks += 1;
1766                }
1767                b = DMatrix::identity(n, n) * settings.initial_hessian_scale;
1768                r.report.hessian_resets += 1;
1769            }
1770            retry_descent = false;
1771            notify = true;
1772        }
1773
1774        r.report.elastic_weight = elastic_weight;
1775        r.status = status.unwrap_or(Status::IterationLimit);
1776        if r.message.is_empty() {
1777            r.message = status_message(r.status).into();
1778        }
1779        r.counts = calls.counts;
1780        r.report.total_seconds = calls.began.elapsed().as_secs_f64();
1781        r.report.callback_seconds = calls.seconds;
1782        Ok(r)
1783    }
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789
1790    #[test]
1791    fn rounding_levels_scale_with_the_terms_of_each_residual() {
1792        // One row with J = (1e-12, 2) and λ = −4e9. Variable 0's stationarity
1793        // sums |1| + |1e-12 · 4e9| + |0.5|, variable 1's |−3| + |2 · 4e9|.
1794        let spec = ProblemSpec::new(
1795            Bounds::new(vec![0.0, 0.0], vec![10.0, 10.0]),
1796            Bounds::new(vec![f64::NEG_INFINITY], vec![0.0]),
1797            CsrPattern::dense(1, 2),
1798        )
1799        .unwrap();
1800        let e = Evaluation {
1801            objective: 0.0,
1802            constraints: vec![2e-12],
1803            gradient: vec![1.0, -3.0],
1804            jacobian_values: vec![1e-12, 2.0],
1805            primal_violation: 2e-12,
1806            feasible_candidate: true,
1807        };
1808        let m = Multipliers {
1809            constraints: vec![-4e9],
1810            variables: vec![0.5, 0.0],
1811        };
1812        let (stationarity, complementarity) = rounding_levels(&spec, &[2.0, 1e-12], &e, &m);
1813        // Three terms for each variable: one row, ∇f and z.
1814        let expected = 3.0 * f64::EPSILON * (3.0 + 8e9);
1815        assert!(
1816            (stationarity / expected - 1.0).abs() < 1e-12,
1817            "{stationarity}"
1818        );
1819        assert!(stationarity > 5e-6);
1820        // |λ|·ε·(|c| + |u|) for the row, 0.5·ε·(2 + 0) for x0 at its lower bound.
1821        let row = 4e9 * f64::EPSILON * 2e-12;
1822        let variable = 0.5 * f64::EPSILON * 2.0;
1823        assert_eq!(complementarity, row.max(variable));
1824    }
1825
1826    #[test]
1827    fn explicit_curvature_blocks_preserve_spd_and_sparse_pattern() {
1828        let n = 7;
1829        let spec =
1830            ProblemSpec::new(Bounds::free(n), Bounds::free(0), CsrPattern::dense(0, n)).unwrap();
1831        let e = Evaluation {
1832            objective: 0.,
1833            constraints: vec![],
1834            gradient: vec![0.; n],
1835            jacobian_values: vec![],
1836            primal_violation: 0.,
1837            feasible_candidate: true,
1838        };
1839        let mut b = DMatrix::identity(n, n);
1840        let mut work = bfgs::Curvature::new(n, 3);
1841        assert!(
1842            work.update(&mut b, &[1.; 7], &[2., 3., 4., 1., 2., 3., 4.], None)
1843                .is_some()
1844        );
1845        assert!(b.clone().cholesky().is_some());
1846        let q = qp_model_with_blocks(&spec, &[0.; 7], &e, &b, 3).unwrap();
1847        q.validate().unwrap();
1848        assert_eq!(q.hessian_lower.values.len(), 13);
1849        for i in 0..n {
1850            for j in 0..n {
1851                if i / 3 != j / 3 {
1852                    assert_eq!(b[(i, j)], 0.);
1853                }
1854            }
1855        }
1856        for i in 0..n {
1857            assert!(
1858                q.hessian_lower
1859                    .pattern
1860                    .row(i)
1861                    .iter()
1862                    .all(|&j| i / 3 == j / 3)
1863            );
1864        }
1865        assert!(
1866            SqpOptions {
1867                hessian_block_size: 257,
1868                ..Default::default()
1869            }
1870            .validate()
1871            .is_err()
1872        );
1873    }
1874    #[test]
1875    fn recycled_callback_buffers_still_reject_missing_entries() {
1876        struct Partial {
1877            missing: bool,
1878        }
1879        impl NlpProblem for Partial {
1880            fn functions(&mut self, x: &[f64], c: &mut [f64]) -> Result<f64> {
1881                c[0] = x[0] + x[1];
1882                Ok(0.)
1883            }
1884            fn derivatives(&mut self, _: &[f64], g: &mut [f64], j: &mut [f64]) -> Result<()> {
1885                if self.missing {
1886                    g[0] = 1.;
1887                } else {
1888                    g.fill(1.);
1889                    j.fill(1.);
1890                }
1891                Ok(())
1892            }
1893        }
1894        let spec =
1895            ProblemSpec::new(Bounds::free(2), Bounds::free(1), CsrPattern::dense(1, 2)).unwrap();
1896        let settings = SqpOptions::default();
1897        let cancel = CancellationToken::default();
1898        let mut calls = Calls {
1899            began: Instant::now(),
1900            settings: &settings,
1901            cancel: &cancel,
1902            counts: EvaluationCounts::default(),
1903            seconds: 0.,
1904            gradient_scratch: vec![],
1905            jacobian_scratch: vec![],
1906        };
1907        let mut model = Partial { missing: false };
1908        let x = [1., 1.];
1909        let v = calls.functions(&spec, &mut model, &x).unwrap();
1910        let e = calls.derivatives(&spec, &mut model, &x, v, 1e-7).unwrap();
1911        calls.recycle(e);
1912        model.missing = true;
1913        let v = calls.functions(&spec, &mut model, &x).unwrap();
1914        assert!(calls.derivatives(&spec, &mut model, &x, v, 1e-7).is_err());
1915    }
1916    #[test]
1917    fn reused_qp_template_matches_fresh_numeric_data() {
1918        let spec = ProblemSpec::new(
1919            Bounds::new(vec![0.; 3], vec![4.; 3]),
1920            Bounds::new(vec![-1., 0.], vec![2., 0.]),
1921            CsrPattern::dense(2, 3),
1922        )
1923        .unwrap();
1924        let mut e = Evaluation {
1925            objective: 0.,
1926            constraints: vec![0., 0.],
1927            gradient: vec![1., 2., 3.],
1928            jacobian_values: vec![1.; 6],
1929            primal_violation: 0.,
1930            feasible_candidate: true,
1931        };
1932        let mut b = DMatrix::identity(3, 3);
1933        let mut q = qp_model(&spec, &[1.; 3], &e, &b).unwrap();
1934        let pointers = (
1935            q.hessian_lower.pattern.indices.as_ptr(),
1936            q.matrix.pattern.indices.as_ptr(),
1937            q.hessian_lower.values.as_ptr(),
1938            q.rows.lower.as_ptr(),
1939        );
1940        for step in 1..5 {
1941            e.constraints[0] = step as f64 / 10.;
1942            e.jacobian_values[2] = step as f64;
1943            b[(1, 1)] = step as f64;
1944            let x = [0.5 + step as f64 / 10.; 3];
1945            refresh_qp(&mut q, &spec, &x, &e, &b).unwrap();
1946            assert_eq!(q, qp_model(&spec, &x, &e, &b).unwrap());
1947            assert_eq!(
1948                pointers,
1949                (
1950                    q.hessian_lower.pattern.indices.as_ptr(),
1951                    q.matrix.pattern.indices.as_ptr(),
1952                    q.hessian_lower.values.as_ptr(),
1953                    q.rows.lower.as_ptr()
1954                )
1955            );
1956        }
1957    }
1958    #[test]
1959    fn a_trial_capped_at_a_bound_stays_inside_the_box() {
1960        // With α = (u − x)/p, x + αp rounds to one ulp above u here (about 2% of
1961        // random cases do); the delivered line search then halved α.
1962        let (x, u, p) = (0.11408985987451992, 0.9812160792571979, 2.958067622106532);
1963        let bounds = Bounds::new(vec![-1.0], vec![u]);
1964        let alpha = box_step_limit(&bounds, &[x], &[p]);
1965        assert!(x + alpha * p > u);
1966        assert_eq!(trial_point(&bounds, &[x], &[p], alpha), vec![u]);
1967        // Interior trials are untouched, and a real overshoot is not hidden.
1968        assert_eq!(
1969            trial_point(&bounds, &[x], &[p], 0.5 * alpha),
1970            vec![x + 0.5 * alpha * p]
1971        );
1972        assert_eq!(trial_point(&bounds, &[x], &[p], 1.0), vec![x + p]);
1973    }
1974
1975    #[test]
1976    fn snapping_moves_only_tolerance_level_overshoots() {
1977        let variables = Bounds::new(vec![0.0, 1.0, -5.0], vec![2.0, 1.0, 5.0]);
1978        let x = [2.0 - 3e-14, 1.0, 0.0];
1979        let room = 2.0 - x[0];
1980        let step_bounds = Bounds::new(vec![-1.0, 0.0, -1.0], vec![room, 0.0, 1.0]);
1981        let mut report = sqp_craft::RunReport::default();
1982        let mut step = vec![room + 1e-11, -2e-12, 1.5];
1983        assert!(box_step_limit(&variables, &x, &step) < 0.01);
1984        snap_step(
1985            &mut step,
1986            &step_bounds,
1987            &variables,
1988            1e-7,
1989            &[1.0; 3],
1990            &mut report,
1991        );
1992        assert_eq!(
1993            step,
1994            vec![room, 0.0, 1.5],
1995            "a 0.5 overshoot is left for the QP check"
1996        );
1997        assert_eq!(
1998            (report.bound_step_corrections, report.fixed_step_corrections),
1999            (1, 1)
2000        );
2001        assert_eq!(box_step_limit(&variables, &x, &step), 1.0);
2002    }
2003}