1#![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
55pub const DENSE_SIZE_CAP: usize = 256;
57pub const QP_TRACE_CAP: usize = 16;
59const ARMIJO: f64 = 1e-4;
61const PENALTY_MARGIN: f64 = 1.1;
65const MERIT_ROUNDING: f64 = 100.0;
68const MAX_NOISE_STEPS: u32 = 3;
71const MAX_ELASTIC_INCREASES: u64 = 6;
75
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub enum HessianUpdate {
80 #[default]
83 Damped,
84 Augmented,
94}
95
96impl HessianUpdate {
97 pub fn as_str(self) -> &'static str {
99 match self {
100 HessianUpdate::Damped => "damped",
101 HessianUpdate::Augmented => "augmented",
102 }
103 }
104
105 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119pub enum Merit {
120 #[default]
123 L1,
124 AugmentedLagrangian,
133}
134
135impl Merit {
136 pub fn as_str(self) -> &'static str {
138 match self {
139 Merit::L1 => "l1",
140 Merit::AugmentedLagrangian => "augmented-lagrangian",
141 }
142 }
143
144 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#[derive(Clone, Debug, PartialEq)]
158pub struct SqpOptions {
159 pub max_iterations: u64,
161 pub max_backtracks: u32,
163 pub max_callback_calls: u64,
166 pub time_limit_seconds: Option<f64>,
168 pub stationarity_tolerance: f64,
170 pub complementarity_tolerance: f64,
172 pub dual_sign_tolerance: f64,
174 pub dense_size_limit: usize,
176 pub initial_hessian_scale: f64,
178 pub hessian_block_size: usize,
180 pub max_hessian_resets: u32,
182 pub qp_tolerance: f64,
184 pub qp_max_iterations: u32,
186 pub qp_reuse_workspace: bool,
188 pub qp_warm_start: bool,
192 pub elastic_mode: bool,
197 pub elastic_weight: f64,
199 pub second_order_correction: bool,
204 pub hessian_update: HessianUpdate,
207 pub merit: Merit,
209 pub scaling: Scaling,
212 pub violation_limit: Option<f64>,
217 pub record_qp_limit: usize,
219 pub record_history: bool,
221 pub max_restoration_steps: u64,
223 pub restoration_step_limit: f64,
225 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 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#[derive(Clone, Debug, Default)]
330pub struct NativeSqp<B> {
331 pub qp: B,
333 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
371fn 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
390fn row_violation(bounds: &Bounds, i: usize, v: f64) -> f64 {
392 (bounds.lower[i] - v).max(v - bounds.upper[i]).max(0.0)
393}
394
395fn 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
404fn 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
410struct 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
508fn 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
525fn 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
561fn 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
575fn 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#[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
643fn 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
676fn 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
702fn 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
715fn 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 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 let linear_reference = r
890 .evaluation
891 .as_ref()
892 .filter(|_| spec.has_linear_rows())
893 .map(|e| e.jacobian_values.clone());
894 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 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 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 let mut qp_warm: Option<QpWarmStart> = None;
951 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 let skip_rows = spec.linear_rows().to_vec();
965 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 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 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 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 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 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 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 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 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 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 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 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 if elastic_phase && elastic_rows.is_empty() {
1299 have_penalties = false;
1303 }
1304 elastic_phase = !elastic_rows.is_empty();
1305 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 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 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 let mut noise_step = None;
1373 if predicted <= 0.0 {
1374 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 let qp = check.residuals;
1395 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 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 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 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 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 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 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 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 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 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 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 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 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}