1use std::time::Instant;
36
37use crate::{
38 certificate::{
39 check_dual_certificate, check_primal_certificate, detect_dual_infeasibility,
40 detect_primal_infeasibility, Certificate,
41 },
42 check_kkt,
43 kkt::{DualVariables, KktResiduals},
44 linalg::{covariance_columns, SmwSystem},
45 matrix::{norm_inf, Matrix},
46 polish::polish,
47 problem::{ProblemError, QpProblem},
48 scaling::ScaledProblem,
49 solver::{
50 validate_settings, ConvergenceDiagnostics, Solution, SolveStatus, SolverError,
51 SolverSettings, WarmStart,
52 },
53};
54
55const FACTORIZATION_CACHE_CAPACITY: usize = 16;
59
60pub struct Workspace {
67 settings: SolverSettings,
68 problem: QpProblem,
71 scaling: Option<ScaledProblem>,
72 systems: Vec<(f64, FactorizedSystem)>,
76 factorizations: usize,
77}
78
79impl Workspace {
80 pub(crate) fn new(settings: &SolverSettings, problem: &QpProblem) -> Result<Self, SolverError> {
83 problem.validate()?;
84 validate_settings(settings)?;
85 let scaling = if settings.scaling_iterations > 0 {
86 Some(ScaledProblem::new(problem, settings.scaling_iterations)?)
87 } else {
88 None
89 };
90 let work = scaling.as_ref().map_or(problem, |scaled| &scaled.problem);
91 let system = FactorizedSystem::new(work, settings.rho, settings.sigma)?;
92 Ok(Self {
93 settings: settings.clone(),
94 problem: problem.clone(),
95 scaling,
96 systems: vec![(settings.rho, system)],
97 factorizations: 1,
98 })
99 }
100
101 #[must_use]
104 pub fn problem(&self) -> &QpProblem {
105 &self.problem
106 }
107
108 #[must_use]
110 pub const fn settings(&self) -> &SolverSettings {
111 &self.settings
112 }
113
114 #[must_use]
118 pub const fn factorizations(&self) -> usize {
119 self.factorizations
120 }
121
122 pub fn update_linear(&mut self, linear: &[f64]) -> Result<(), SolverError> {
133 replace_vector("linear", &mut self.problem.linear, linear)?;
134 if let Some(scaled) = &mut self.scaling {
135 scaled.set_linear(linear);
136 }
137 Ok(())
138 }
139
140 pub fn update_l1_anchor(&mut self, anchor: &[f64]) -> Result<(), SolverError> {
153 let Some(term) = &mut self.problem.l1 else {
154 return Err(SolverError::InvalidProblem(ProblemError::Dimension {
155 field: "l1.anchor",
156 expected: 0,
157 actual: anchor.len(),
158 }));
159 };
160 replace_vector("l1.anchor", &mut term.anchor, anchor)?;
161 if let Some(scaled) = &mut self.scaling {
162 scaled.set_l1_anchor(anchor);
163 }
164 Ok(())
165 }
166
167 pub fn update_equality_rhs(&mut self, rhs: &[f64]) -> Result<(), SolverError> {
176 replace_vector("equalities.rhs", &mut self.problem.equalities.rhs, rhs)?;
177 if let Some(scaled) = &mut self.scaling {
178 scaled.set_equality_rhs(rhs);
179 }
180 Ok(())
181 }
182
183 pub fn update_inequality_rhs(&mut self, rhs: &[f64]) -> Result<(), SolverError> {
193 replace_vector("inequalities.rhs", &mut self.problem.inequalities.rhs, rhs)?;
194 if let Some(scaled) = &mut self.scaling {
195 scaled.set_inequality_rhs(rhs);
196 }
197 Ok(())
198 }
199
200 pub fn solve(&mut self, warm_start: Option<&WarmStart>) -> Result<Solution, SolverError> {
218 self.solve_from(Instant::now(), warm_start)
219 }
220
221 pub(crate) fn solve_from(
224 &mut self,
225 started: Instant,
226 warm_start: Option<&WarmStart>,
227 ) -> Result<Solution, SolverError> {
228 let settings = self.settings.clone();
229 let work = self
232 .scaling
233 .as_ref()
234 .map_or(&self.problem, |scaled| &scaled.problem);
235 let systems = &mut self.systems;
236 let factorizations = &mut self.factorizations;
237
238 let n = self.problem.quadratic.dimension();
239 let equality_count = self.problem.equalities.len();
240 let inequality_count = self.problem.inequalities.len();
241 let l1_count = if self.problem.l1.is_some() { n } else { 0 };
242 let mut x = if let Some(warm) = warm_start {
243 validate_warm_vector("x", &warm.x, n)?;
244 warm.x.clone()
245 } else {
246 vec![0.0; n]
247 };
248 let mut dual = DualVariables {
249 equalities: warm_vector(
250 warm_start.and_then(|warm| warm.equality_dual.as_deref()),
251 "equality_dual",
252 equality_count,
253 )?,
254 inequalities: warm_vector(
255 warm_start.and_then(|warm| warm.inequality_dual.as_deref()),
256 "inequality_dual",
257 inequality_count,
258 )?,
259 bounds: warm_vector(
260 warm_start.and_then(|warm| warm.bound_dual.as_deref()),
261 "bound_dual",
262 n,
263 )?,
264 l1: warm_vector(
265 warm_start.and_then(|warm| warm.l1_dual.as_deref()),
266 "l1_dual",
267 l1_count,
268 )?,
269 };
270 if let Some(scaled) = &self.scaling {
271 scaled.scale_iterates_in_place(&mut x, &mut dual);
272 }
273
274 let mut rho = settings.rho;
275 ensure_system(systems, factorizations, work, rho, settings.sigma)?;
276 let mut equality_slack = vec![0.0; equality_count];
277 let initial_inequality = work.inequalities.matrix.mul_vec(&x);
278 let mut inequality_slack: Vec<f64> = initial_inequality
279 .iter()
280 .zip(&dual.inequalities)
281 .zip(&work.inequalities.rhs)
282 .map(|((value, multiplier), rhs)| (value + multiplier / rho).min(*rhs))
283 .collect();
284 equality_slack.copy_from_slice(&work.equalities.rhs);
285 let mut bound_slack: Vec<f64> = x
286 .iter()
287 .zip(&dual.bounds)
288 .enumerate()
289 .map(|(index, (value, multiplier))| {
290 project(
291 value + multiplier / rho,
292 work.lower_bounds[index],
293 work.upper_bounds[index],
294 )
295 })
296 .collect();
297 let mut l1_slack: Vec<f64> = work.l1.as_ref().map_or_else(Vec::new, |term| {
300 x.iter()
301 .zip(&dual.l1)
302 .enumerate()
303 .map(|(index, (value, multiplier))| {
304 soft_threshold(
305 value - term.anchor[index] + multiplier / rho,
306 term.costs[index] / rho,
307 )
308 })
309 .collect()
310 });
311
312 let mut status = SolveStatus::MaxIterations;
313 let mut completed_iterations = 0;
314 let mut rho_updates = 0;
315 let mut certificate: Option<Certificate> = None;
316 let detect_infeasibility = settings.infeasibility_tolerance > 0.0;
321 let (mut checked_x, mut checked_dual) = if detect_infeasibility {
322 unscaled_iterates(self.scaling.as_ref(), &x, &dual)
323 } else {
324 (Vec::new(), DualVariables::default())
325 };
326
327 for iteration in 1..=settings.max_iterations {
328 let mut right_hand_side: Vec<f64> = x
329 .iter()
330 .zip(&work.linear)
331 .map(|(value, linear)| settings.sigma * value - linear)
332 .collect();
333
334 let equality_weight: Vec<f64> = equality_slack
335 .iter()
336 .zip(&dual.equalities)
337 .map(|(slack, multiplier)| rho * slack - multiplier)
338 .collect();
339 work.equalities
340 .matrix
341 .transpose_mul_add(&equality_weight, &mut right_hand_side);
342 let inequality_weight: Vec<f64> = inequality_slack
343 .iter()
344 .zip(&dual.inequalities)
345 .map(|(slack, multiplier)| rho * slack - multiplier)
346 .collect();
347 work.inequalities
348 .matrix
349 .transpose_mul_add(&inequality_weight, &mut right_hand_side);
350 for ((right_hand_side, slack), multiplier) in right_hand_side
351 .iter_mut()
352 .zip(&bound_slack)
353 .zip(&dual.bounds)
354 {
355 *right_hand_side += rho * slack - multiplier;
356 }
357 if let Some(term) = &work.l1 {
358 for (((right_hand_side, slack), multiplier), anchor) in right_hand_side
361 .iter_mut()
362 .zip(&l1_slack)
363 .zip(&dual.l1)
364 .zip(&term.anchor)
365 {
366 *right_hand_side += rho * (anchor + slack) - multiplier;
367 }
368 }
369
370 systems[0].1.solve_in_place(&mut right_hand_side);
372 x = right_hand_side;
373 if x.iter().any(|value| !value.is_finite()) {
374 status = SolveStatus::NumericalFailure;
375 completed_iterations = iteration;
376 break;
377 }
378
379 let equality_values = work.equalities.matrix.mul_vec(&x);
380 let inequality_values = work.inequalities.matrix.mul_vec(&x);
381 let adapt_rho = settings.adaptive_rho
382 && iteration % settings.adaptive_rho_interval == 0
383 && iteration < settings.max_iterations;
384 let old_inequality_slack = if adapt_rho {
385 inequality_slack.clone()
386 } else {
387 Vec::new()
388 };
389 let old_bound_slack = if adapt_rho {
390 bound_slack.clone()
391 } else {
392 Vec::new()
393 };
394 let old_l1_slack = if adapt_rho {
395 l1_slack.clone()
396 } else {
397 Vec::new()
398 };
399 let alpha = settings.over_relaxation;
403 for ((multiplier, value), slack) in dual
404 .equalities
405 .iter_mut()
406 .zip(&equality_values)
407 .zip(&equality_slack)
408 {
409 let relaxed = alpha * value + (1.0 - alpha) * slack;
410 *multiplier += rho * (relaxed - slack);
411 }
412 for (((slack, multiplier), value), rhs) in inequality_slack
413 .iter_mut()
414 .zip(&mut dual.inequalities)
415 .zip(&inequality_values)
416 .zip(&work.inequalities.rhs)
417 {
418 let relaxed = alpha * value + (1.0 - alpha) * *slack;
419 *slack = (relaxed + *multiplier / rho).min(*rhs);
420 *multiplier += rho * (relaxed - *slack);
421 }
422 for (index, ((slack, multiplier), value)) in bound_slack
423 .iter_mut()
424 .zip(&mut dual.bounds)
425 .zip(&x)
426 .enumerate()
427 {
428 let relaxed = alpha * value + (1.0 - alpha) * *slack;
429 *slack = project(
430 relaxed + *multiplier / rho,
431 work.lower_bounds[index],
432 work.upper_bounds[index],
433 );
434 *multiplier += rho * (relaxed - *slack);
435 }
436 if let Some(term) = &work.l1 {
437 for (index, ((slack, multiplier), value)) in
440 l1_slack.iter_mut().zip(&mut dual.l1).zip(&x).enumerate()
441 {
442 let consensus = value - term.anchor[index];
443 let relaxed = alpha * consensus + (1.0 - alpha) * *slack;
444 *slack = soft_threshold(relaxed + *multiplier / rho, term.costs[index] / rho);
445 *multiplier += rho * (relaxed - *slack);
446 }
447 }
448 completed_iterations = iteration;
449
450 if iteration % settings.check_termination_every == 0
451 || iteration == settings.max_iterations
452 {
453 let (original_x, original_dual) =
460 unscaled_iterates(self.scaling.as_ref(), &x, &dual);
461 let residuals = check_kkt(&self.problem, &original_x, &original_dual)?;
462 let (primal_tolerance, dual_tolerance) =
463 stopping_tolerances(&self.problem, &original_x, &original_dual, &settings);
464 if residuals.primal <= primal_tolerance
465 && residuals.dual <= dual_tolerance
466 && residuals.complementarity <= primal_tolerance.max(dual_tolerance)
467 {
468 status = SolveStatus::Solved;
469 break;
470 }
471 if detect_infeasibility {
472 let delta_dual = DualVariables {
473 equalities: difference(&original_dual.equalities, &checked_dual.equalities),
474 inequalities: difference(
475 &original_dual.inequalities,
476 &checked_dual.inequalities,
477 ),
478 bounds: difference(&original_dual.bounds, &checked_dual.bounds),
479 l1: Vec::new(),
483 };
484 if let Some(found) = detect_primal_infeasibility(
485 &self.problem,
486 &delta_dual,
487 settings.infeasibility_tolerance,
488 ) {
489 certificate = Some(Certificate::Primal(found));
490 status = SolveStatus::PrimalInfeasible;
491 break;
492 }
493 let delta_x = difference(&original_x, &checked_x);
494 if let Some(found) = detect_dual_infeasibility(
495 &self.problem,
496 &delta_x,
497 settings.infeasibility_tolerance,
498 ) {
499 certificate = Some(Certificate::Dual(found));
500 status = SolveStatus::DualInfeasible;
501 break;
502 }
503 checked_x = original_x;
504 checked_dual = original_dual;
505 }
506 }
507
508 if adapt_rho {
509 let primal_residual = consensus_primal_residual(
510 work,
511 &equality_values,
512 &equality_slack,
513 &inequality_values,
514 &inequality_slack,
515 &x,
516 &bound_slack,
517 &l1_slack,
518 );
519 let dual_residual = consensus_dual_residual(
520 work,
521 &old_inequality_slack,
522 &inequality_slack,
523 &old_bound_slack,
524 &bound_slack,
525 &old_l1_slack,
526 &l1_slack,
527 rho,
528 );
529 let next_rho = balanced_rho(rho, primal_residual, dual_residual, &settings);
530 if next_rho.to_bits() != rho.to_bits() {
531 ensure_system(systems, factorizations, work, next_rho, settings.sigma)?;
532 rho = next_rho;
533 rho_updates += 1;
534 }
535 }
536 }
537
538 let (mut x, mut dual) = unscaled_iterates(self.scaling.as_ref(), &x, &dual);
539 let mut residuals = if status == SolveStatus::NumericalFailure {
540 KktResiduals {
541 primal: f64::INFINITY,
542 dual: f64::INFINITY,
543 complementarity: f64::INFINITY,
544 }
545 } else {
546 check_kkt(&self.problem, &x, &dual)?
547 };
548 let mut polished = false;
552 if status == SolveStatus::Solved && settings.polish {
553 if let Some(improved) = polish(&self.problem, &x, &dual, &residuals, &settings) {
554 x = improved.x;
555 dual = improved.dual;
556 residuals = improved.residuals;
557 polished = true;
558 }
559 }
560 let diagnostics = if status == SolveStatus::Solved {
561 None
562 } else {
563 Some(diagnose_failure(
564 &self.problem,
565 &x,
566 &dual,
567 &residuals,
568 status,
569 certificate.as_ref(),
570 rho,
571 rho_updates,
572 &settings,
573 ))
574 };
575 Ok(Solution {
576 status,
577 objective: self.problem.objective(&x),
578 x,
579 dual,
580 residuals,
581 iterations: completed_iterations,
582 solve_time: started.elapsed(),
583 final_rho: rho,
584 rho_updates,
585 polished,
586 diagnostics,
587 certificate,
588 })
589 }
590}
591
592fn difference(current: &[f64], previous: &[f64]) -> Vec<f64> {
594 current
595 .iter()
596 .zip(previous)
597 .map(|(new, old)| new - old)
598 .collect()
599}
600
601fn ensure_system(
607 systems: &mut Vec<(f64, FactorizedSystem)>,
608 factorizations: &mut usize,
609 work: &QpProblem,
610 rho: f64,
611 sigma: f64,
612) -> Result<(), SolverError> {
613 if let Some(position) = systems
614 .iter()
615 .position(|(cached, _)| cached.to_bits() == rho.to_bits())
616 {
617 let entry = systems.remove(position);
618 systems.insert(0, entry);
619 return Ok(());
620 }
621 let system = FactorizedSystem::new(work, rho, sigma)?;
622 *factorizations += 1;
623 systems.insert(0, (rho, system));
624 systems.truncate(FACTORIZATION_CACHE_CAPACITY);
625 Ok(())
626}
627
628fn replace_vector(
629 field: &'static str,
630 target: &mut [f64],
631 values: &[f64],
632) -> Result<(), SolverError> {
633 if values.len() != target.len() {
634 return Err(SolverError::InvalidProblem(ProblemError::Dimension {
635 field,
636 expected: target.len(),
637 actual: values.len(),
638 }));
639 }
640 if values.iter().any(|value| !value.is_finite()) {
641 return Err(SolverError::InvalidProblem(ProblemError::NonFinite(field)));
642 }
643 target.copy_from_slice(values);
644 Ok(())
645}
646
647pub(crate) struct FactorizedSystem {
648 system: SmwSystem,
649}
650
651impl FactorizedSystem {
652 pub(crate) fn new(problem: &QpProblem, rho: f64, sigma: f64) -> Result<Self, SolverError> {
653 let covariance = covariance_columns(&problem.quadratic)?;
654 let n = problem.quadratic.dimension();
655 let factor_count = problem.quadratic.factor_count();
656 let constraint_count = problem.equalities.len() + problem.inequalities.len();
657 let reduced_dimension = factor_count + constraint_count;
658 let mut columns = Matrix::zeros(n, reduced_dimension);
659 for row in 0..n {
660 for col in 0..factor_count {
661 columns[(row, col)] = covariance[(row, col)];
662 }
663 }
664 let rho_root = rho.sqrt();
665 for constraint in 0..problem.equalities.len() {
666 for variable in 0..n {
667 columns[(variable, factor_count + constraint)] =
668 rho_root * problem.equalities.matrix[(constraint, variable)];
669 }
670 }
671 let inequality_offset = factor_count + problem.equalities.len();
672 for constraint in 0..problem.inequalities.len() {
673 for variable in 0..n {
674 columns[(variable, inequality_offset + constraint)] =
675 rho_root * problem.inequalities.matrix[(constraint, variable)];
676 }
677 }
678
679 let l1_rho = if problem.l1.is_some() { rho } else { 0.0 };
684 let diagonal: Vec<f64> = problem
685 .quadratic
686 .diagonal
687 .iter()
688 .map(|diagonal| diagonal + sigma + rho + l1_rho)
689 .collect();
690 Ok(Self {
691 system: SmwSystem::factor(&diagonal, columns)?,
692 })
693 }
694
695 pub(crate) fn solve_in_place(&self, right_hand_side: &mut [f64]) {
696 self.system.solve_in_place(right_hand_side);
697 }
698}
699
700fn project(value: f64, lower: f64, upper: f64) -> f64 {
701 value.max(lower).min(upper)
702}
703
704fn soft_threshold(value: f64, threshold: f64) -> f64 {
707 if value > threshold {
708 value - threshold
709 } else if value < -threshold {
710 value + threshold
711 } else {
712 0.0
713 }
714}
715
716fn unscaled_iterates(
719 scaling: Option<&ScaledProblem>,
720 x: &[f64],
721 dual: &DualVariables,
722) -> (Vec<f64>, DualVariables) {
723 scaling.map_or_else(
724 || (x.to_vec(), dual.clone()),
725 |scaled| (scaled.unscaled_x(x), scaled.unscaled_dual(dual)),
726 )
727}
728
729#[allow(clippy::too_many_arguments)]
730fn consensus_primal_residual(
731 problem: &QpProblem,
732 equality_values: &[f64],
733 equality_slack: &[f64],
734 inequality_values: &[f64],
735 inequality_slack: &[f64],
736 x: &[f64],
737 bound_slack: &[f64],
738 l1_slack: &[f64],
739) -> f64 {
740 let l1_residual = problem.l1.as_ref().map_or(0.0, |term| {
741 x.iter()
742 .zip(&term.anchor)
743 .zip(l1_slack)
744 .map(|((value, anchor), slack)| (value - anchor - slack).abs())
745 .fold(0.0, f64::max)
746 });
747 equality_values
748 .iter()
749 .zip(equality_slack)
750 .chain(inequality_values.iter().zip(inequality_slack))
751 .map(|(value, slack)| (value - slack).abs())
752 .chain(
753 x.iter()
754 .zip(bound_slack)
755 .map(|(value, slack)| (value - slack).abs()),
756 )
757 .fold(l1_residual, f64::max)
758}
759
760#[allow(clippy::too_many_arguments)]
761fn consensus_dual_residual(
762 problem: &QpProblem,
763 old_inequality_slack: &[f64],
764 inequality_slack: &[f64],
765 old_bound_slack: &[f64],
766 bound_slack: &[f64],
767 old_l1_slack: &[f64],
768 l1_slack: &[f64],
769 rho: f64,
770) -> f64 {
771 let inequality_change: Vec<f64> = inequality_slack
772 .iter()
773 .zip(old_inequality_slack)
774 .map(|(new, old)| new - old)
775 .collect();
776 let mut dual_change: Vec<f64> = bound_slack
777 .iter()
778 .zip(old_bound_slack)
779 .map(|(new, old)| new - old)
780 .collect();
781 for (change, (new, old)) in dual_change
783 .iter_mut()
784 .zip(l1_slack.iter().zip(old_l1_slack))
785 {
786 *change += new - old;
787 }
788 problem
789 .inequalities
790 .matrix
791 .transpose_mul_add(&inequality_change, &mut dual_change);
792 rho * norm_inf(&dual_change)
793}
794
795fn balanced_rho(
796 rho: f64,
797 primal_residual: f64,
798 dual_residual: f64,
799 settings: &SolverSettings,
800) -> f64 {
801 if primal_residual > settings.adaptive_rho_tolerance * dual_residual {
802 (rho * settings.adaptive_rho_multiplier).min(settings.maximum_rho)
803 } else if dual_residual > settings.adaptive_rho_tolerance * primal_residual {
804 (rho / settings.adaptive_rho_multiplier).max(settings.minimum_rho)
805 } else {
806 rho
807 }
808}
809
810fn validate_warm_vector(
811 field: &'static str,
812 values: &[f64],
813 expected: usize,
814) -> Result<(), SolverError> {
815 if values.len() != expected {
816 return Err(SolverError::WarmStartDimension {
817 field,
818 expected,
819 actual: values.len(),
820 });
821 }
822 if values.iter().any(|value| !value.is_finite()) {
823 return Err(SolverError::WarmStartNonFinite(field));
824 }
825 Ok(())
826}
827
828fn warm_vector(
829 values: Option<&[f64]>,
830 field: &'static str,
831 expected: usize,
832) -> Result<Vec<f64>, SolverError> {
833 if let Some(values) = values {
834 validate_warm_vector(field, values, expected)?;
835 Ok(values.to_vec())
836 } else {
837 Ok(vec![0.0; expected])
838 }
839}
840
841#[allow(clippy::too_many_arguments)]
842fn diagnose_failure(
843 problem: &QpProblem,
844 x: &[f64],
845 dual: &DualVariables,
846 residuals: &KktResiduals,
847 status: SolveStatus,
848 certificate: Option<&Certificate>,
849 rho: f64,
850 rho_updates: usize,
851 settings: &SolverSettings,
852) -> ConvergenceDiagnostics {
853 let (primal_tolerance, dual_tolerance) = stopping_tolerances(problem, x, dual, settings);
854 let coefficient_spread_decades = coefficient_spread_decades(problem);
855 let rho_at_limit = rho <= settings.minimum_rho || rho >= settings.maximum_rho;
856
857 let mut hints = Vec::new();
858 match certificate {
859 Some(Certificate::Primal(primal)) => {
860 if let Ok(audited) = check_primal_certificate(problem, primal) {
861 hints.push(format!(
862 "a Farkas certificate proves the constraints admit no common point: \
863 the weighted constraint combination in Solution::certificate cancels \
864 every variable (stationarity residual {:.1e}) yet requires a value \
865 below {:.3e}; relax one of the participating constraints \
866 (audit independently with check_primal_certificate)",
867 audited.stationarity, audited.support_gap
868 ));
869 }
870 }
871 Some(Certificate::Dual(dual_certificate)) => {
872 if let Ok(audited) = check_dual_certificate(problem, dual_certificate) {
873 hints.push(format!(
874 "a descent-ray certificate proves the objective is unbounded below: \
875 along Solution::certificate's direction the quadratic cost stays at \
876 {:.1e} while the linear cost falls at slope {:.3e} and no constraint \
877 blocks the ray; add bounds or constraints capping this direction \
878 (audit independently with check_dual_certificate)",
879 audited.curvature, audited.objective_gap
880 ));
881 }
882 }
883 None => {}
884 }
885 if status == SolveStatus::NumericalFailure {
886 hints.push(
887 "an iterate became non-finite; check the input for extreme coefficient \
888 magnitudes or near-singular covariance data"
889 .to_owned(),
890 );
891 }
892 if coefficient_spread_decades > 6.0 {
893 if settings.scaling_iterations == 0 {
894 hints.push(format!(
895 "problem coefficients span about {coefficient_spread_decades:.0} orders of \
896 magnitude and automatic scaling is disabled; set scaling_iterations \
897 to its default (10) or rescale returns, covariance, and constraints \
898 toward comparable units"
899 ));
900 } else {
901 hints.push(format!(
902 "problem coefficients span about {coefficient_spread_decades:.0} orders of \
903 magnitude even though Ruiz equilibration ran; rescale returns, \
904 covariance, and constraints toward comparable units before solving"
905 ));
906 }
907 }
908 if rho_at_limit {
909 hints.push(format!(
910 "the adaptive penalty finished pinned at {rho:.1e} (allowed range \
911 [{:.1e}, {:.1e}]); widen minimum_rho/maximum_rho or start rho closer to \
912 this value",
913 settings.minimum_rho, settings.maximum_rho
914 ));
915 }
916 let primal_gap = residuals.primal / primal_tolerance.max(f64::MIN_POSITIVE);
917 let dual_gap = residuals.dual / dual_tolerance.max(f64::MIN_POSITIVE);
918 if status == SolveStatus::MaxIterations {
919 if primal_gap.max(dual_gap) < 100.0 {
920 hints.push(format!(
921 "residuals are within two orders of magnitude of tolerance \
922 (primal {:.1e} vs {:.1e}, dual {:.1e} vs {:.1e}); raising \
923 max_iterations above {} will likely finish the solve",
924 residuals.primal,
925 primal_tolerance,
926 residuals.dual,
927 dual_tolerance,
928 settings.max_iterations
929 ));
930 } else if primal_gap > 100.0 * dual_gap.max(1.0) {
931 hints.push(
932 "the primal residual dominates and no infeasibility certificate was \
933 found within infeasibility_tolerance; the constraints may be feasible \
934 only barely, or infeasible by a margin below that tolerance — verify \
935 that budget, boxes, and linear constraints admit a common point"
936 .to_owned(),
937 );
938 } else if dual_gap > 100.0 * primal_gap.max(1.0) {
939 hints.push(
940 "the dual residual dominates; consider loosening relative_tolerance, \
941 increasing rho, or rescaling the objective so risk and return terms \
942 have comparable magnitude"
943 .to_owned(),
944 );
945 } else {
946 hints.push(format!(
947 "both residuals remain far from tolerance after {} iterations; \
948 rescale the problem data or relax tolerances before raising the \
949 iteration budget",
950 settings.max_iterations
951 ));
952 }
953 }
954 if rho_updates > 10 {
955 hints.push(format!(
956 "rho was re-tuned {rho_updates} times, a sign of conflicting primal/dual \
957 scales; data rescaling usually helps more than iteration budget"
958 ));
959 }
960
961 ConvergenceDiagnostics {
962 primal_tolerance,
963 dual_tolerance,
964 coefficient_spread_decades,
965 rho_at_limit,
966 hints,
967 }
968}
969
970fn coefficient_spread_decades(problem: &QpProblem) -> f64 {
973 let mut smallest = f64::INFINITY;
974 let mut largest = 0.0_f64;
975 let mut observe = |value: f64| {
976 let magnitude = value.abs();
977 if magnitude.is_finite() && magnitude > 0.0 {
978 smallest = smallest.min(magnitude);
979 largest = largest.max(magnitude);
980 }
981 };
982 for value in problem.quadratic.factors.as_slice() {
983 observe(*value);
984 }
985 for value in &problem.quadratic.diagonal {
986 observe(*value);
987 }
988 for value in &problem.linear {
989 observe(*value);
990 }
991 if let Some(l1) = &problem.l1 {
992 for value in l1.costs.iter().chain(&l1.anchor) {
993 observe(*value);
994 }
995 }
996 for constraints in [&problem.equalities, &problem.inequalities] {
997 for value in constraints.matrix.as_slice() {
998 observe(*value);
999 }
1000 for value in &constraints.rhs {
1001 observe(*value);
1002 }
1003 }
1004 if largest == 0.0 || !smallest.is_finite() {
1005 0.0
1006 } else {
1007 (largest / smallest).log10()
1008 }
1009}
1010
1011fn stopping_tolerances(
1012 problem: &QpProblem,
1013 x: &[f64],
1014 dual: &DualVariables,
1015 settings: &SolverSettings,
1016) -> (f64, f64) {
1017 let equality_values = problem.equalities.matrix.mul_vec(x);
1018 let inequality_values = problem.inequalities.matrix.mul_vec(x);
1019 let primal_scale = 1.0_f64
1020 .max(norm_inf(x))
1021 .max(norm_inf(&equality_values))
1022 .max(norm_inf(&problem.equalities.rhs))
1023 .max(norm_inf(&inequality_values))
1024 .max(norm_inf(&problem.inequalities.rhs));
1025
1026 let qx = problem.quadratic.apply(x);
1027 let mut transposed_dual = vec![0.0; x.len()];
1028 problem
1029 .equalities
1030 .matrix
1031 .transpose_mul_add(&dual.equalities, &mut transposed_dual);
1032 problem
1033 .inequalities
1034 .matrix
1035 .transpose_mul_add(&dual.inequalities, &mut transposed_dual);
1036 for (value, bound) in transposed_dual.iter_mut().zip(&dual.bounds) {
1037 *value += bound;
1038 }
1039 for (value, l1) in transposed_dual.iter_mut().zip(&dual.l1) {
1040 *value += l1;
1041 }
1042 let l1_cost_scale = problem
1043 .l1
1044 .as_ref()
1045 .map_or(0.0, |term| norm_inf(&term.costs));
1046 let dual_scale = 1.0_f64
1047 .max(norm_inf(&qx))
1048 .max(norm_inf(&problem.linear))
1049 .max(l1_cost_scale)
1050 .max(norm_inf(&transposed_dual));
1051
1052 (
1053 settings.absolute_tolerance + settings.relative_tolerance * primal_scale,
1054 settings.absolute_tolerance + settings.relative_tolerance * dual_scale,
1055 )
1056}