ledge_core/solver.rs
1//! ADMM solver with a factor-structured linear system and adaptive penalty.
2//!
3//! The iteration engine and the factorization cache live in
4//! [`crate::workspace`]; this module owns the public one-shot entry point,
5//! settings, statuses, and diagnostics.
6
7use std::{
8 fmt,
9 time::{Duration, Instant},
10};
11
12use thiserror::Error;
13
14use crate::{
15 certificate::Certificate,
16 kkt::{DualVariables, KktError, KktResiduals},
17 problem::{ProblemError, QpProblem},
18 workspace::Workspace,
19};
20
21/// Solver termination status.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum SolveStatus {
25 /// KKT residuals met the configured absolute/relative tolerances.
26 Solved,
27 /// The iteration budget was exhausted.
28 MaxIterations,
29 /// An iterate became NaN or infinite.
30 NumericalFailure,
31 /// The constraints admit no common point; a Farkas certificate is
32 /// attached to [`Solution::certificate`].
33 PrimalInfeasible,
34 /// The objective is unbounded below over the constraints; a descent-ray
35 /// certificate is attached to [`Solution::certificate`].
36 DualInfeasible,
37}
38
39impl fmt::Display for SolveStatus {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 formatter.write_str(match self {
42 Self::Solved => "solved",
43 Self::MaxIterations => "maximum iterations reached",
44 Self::NumericalFailure => "numerical failure",
45 Self::PrimalInfeasible => "primal infeasible",
46 Self::DualInfeasible => "dual infeasible",
47 })
48 }
49}
50
51/// ADMM settings.
52#[derive(Clone, Debug, PartialEq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54pub struct SolverSettings {
55 /// Maximum number of ADMM iterations.
56 pub max_iterations: usize,
57 /// Absolute stopping tolerance.
58 pub absolute_tolerance: f64,
59 /// Relative stopping tolerance.
60 pub relative_tolerance: f64,
61 /// Initial augmented-Lagrangian penalty.
62 pub rho: f64,
63 /// Positive proximal regularization in the x update.
64 pub sigma: f64,
65 /// Evaluate KKT residuals every this many iterations.
66 pub check_termination_every: usize,
67 /// Whether residual balancing may update `rho`.
68 pub adaptive_rho: bool,
69 /// Consider a penalty update every this many iterations.
70 pub adaptive_rho_interval: usize,
71 /// Update when one ADMM residual exceeds the other by this ratio.
72 pub adaptive_rho_tolerance: f64,
73 /// Multiplicative increase or decrease applied to `rho`.
74 pub adaptive_rho_multiplier: f64,
75 /// Over-relaxation coefficient `alpha` blended into every consensus
76 /// update: the slack and multiplier steps see
77 /// `alpha * Ax + (1 - alpha) * z_prev` instead of `Ax`.
78 ///
79 /// Must lie in the open interval `(0, 2)`; `1.0` disables relaxation and
80 /// recovers plain ADMM. Values around `1.6` typically cut iteration
81 /// counts substantially on feasible convex QPs (see Boyd et al. §3.4.3
82 /// and the OSQP default).
83 pub over_relaxation: f64,
84 /// Smallest penalty selected by adaptation.
85 pub minimum_rho: f64,
86 /// Largest penalty selected by adaptation.
87 pub maximum_rho: f64,
88 /// Number of Ruiz equilibration passes over the problem data before
89 /// iterating; `0` disables automatic scaling.
90 ///
91 /// Scaling only changes the space ADMM iterates in. Termination checks and
92 /// every reported residual are always evaluated on the original data.
93 pub scaling_iterations: usize,
94 /// Tolerance for declaring the problem primal or dual infeasible from
95 /// ADMM iterate differences; `0` disables infeasibility detection.
96 ///
97 /// Candidate certificates are normalized to unit infinity norm and
98 /// evaluated on the original data, so the tolerance is relative: a
99 /// direction qualifies when its defining residuals are below the
100 /// tolerance and its gap is below the negated tolerance (see
101 /// [`crate::check_primal_certificate`] /
102 /// [`crate::check_dual_certificate`]). Problems infeasible by a margin
103 /// smaller than this tolerance stop at [`SolveStatus::MaxIterations`]
104 /// with diagnostics instead.
105 pub infeasibility_tolerance: f64,
106 /// Whether to refine `Solved` iterates with one direct active-set solve
107 /// (polishing).
108 ///
109 /// The active set is guessed from the final multipliers and the
110 /// resulting KKT system is solved through the same SMW reduction the
111 /// iterations use, at the cost of one extra reduced factorization. The
112 /// polished iterate is adopted only when its worst KKT residual —
113 /// re-audited with [`crate::check_kkt`] on the original data — improves
114 /// on the ADMM iterate's, so enabling polish never degrades a solution.
115 /// [`Solution::polished`] records the outcome.
116 pub polish: bool,
117 /// Regularization added to the polishing KKT system so it stays
118 /// factorable even when the active-set guess is degenerate.
119 ///
120 /// The regularization error is removed afterwards by
121 /// [`SolverSettings::polish_refinement_iterations`] rounds of iterative
122 /// refinement against the unregularized system.
123 pub polish_regularization: f64,
124 /// Iterative-refinement rounds applied to the polishing solve.
125 pub polish_refinement_iterations: usize,
126}
127
128impl Default for SolverSettings {
129 fn default() -> Self {
130 Self {
131 max_iterations: 10_000,
132 absolute_tolerance: 1.0e-6,
133 relative_tolerance: 1.0e-5,
134 rho: 1.0,
135 sigma: 1.0e-6,
136 check_termination_every: 10,
137 adaptive_rho: true,
138 adaptive_rho_interval: 25,
139 adaptive_rho_tolerance: 5.0,
140 adaptive_rho_multiplier: 2.0,
141 over_relaxation: 1.6,
142 minimum_rho: 1.0e-6,
143 maximum_rho: 1.0e6,
144 scaling_iterations: 10,
145 infeasibility_tolerance: 1.0e-5,
146 polish: true,
147 polish_regularization: 1.0e-6,
148 polish_refinement_iterations: 3,
149 }
150 }
151}
152
153/// Optional initial primal and dual iterates.
154///
155/// Missing multiplier blocks are initialized to zero. Warm starts reuse
156/// iterates; to also reuse the equilibration and the reduced factorization
157/// across solves, keep a [`Workspace`](crate::Workspace).
158#[derive(Clone, Debug, Default, PartialEq)]
159#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
160pub struct WarmStart {
161 /// Initial decision vector.
162 pub x: Vec<f64>,
163 /// Optional equality multipliers.
164 pub equality_dual: Option<Vec<f64>>,
165 /// Optional inequality multipliers.
166 pub inequality_dual: Option<Vec<f64>>,
167 /// Optional box normal-cone multipliers.
168 pub bound_dual: Option<Vec<f64>>,
169 /// Optional L1 subgradient multipliers (problems with an
170 /// [`L1Term`](crate::L1Term) only).
171 pub l1_dual: Option<Vec<f64>>,
172}
173
174impl WarmStart {
175 /// Constructs a primal-only warm start.
176 #[must_use]
177 pub fn from_primal(x: Vec<f64>) -> Self {
178 Self {
179 x,
180 ..Self::default()
181 }
182 }
183}
184
185/// Heuristic diagnosis of a solve that stopped before convergence.
186///
187/// Attached to [`Solution::diagnostics`] when the status is not
188/// [`SolveStatus::Solved`]. Hints are heuristics ordered by likely relevance,
189/// not certificates; they exist to make `MaxIterations` actionable instead of
190/// a dead end.
191#[derive(Clone, Debug, Default, PartialEq)]
192#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
193pub struct ConvergenceDiagnostics {
194 /// Primal stopping tolerance in force at the final iterate.
195 pub primal_tolerance: f64,
196 /// Dual stopping tolerance in force at the final iterate.
197 pub dual_tolerance: f64,
198 /// Base-10 orders of magnitude spanned by nonzero problem coefficients.
199 pub coefficient_spread_decades: f64,
200 /// Whether the final penalty sat at `minimum_rho` or `maximum_rho`.
201 pub rho_at_limit: bool,
202 /// Human-readable tuning hints ordered by likely relevance.
203 pub hints: Vec<String>,
204}
205
206/// Solver result and diagnostics.
207#[derive(Clone, Debug, PartialEq)]
208#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209pub struct Solution {
210 /// Termination status.
211 pub status: SolveStatus,
212 /// Primal decision vector.
213 pub x: Vec<f64>,
214 /// Constraint multipliers.
215 pub dual: DualVariables,
216 /// Objective value at the returned iterate.
217 pub objective: f64,
218 /// KKT residuals at the returned iterate.
219 pub residuals: KktResiduals,
220 /// Number of completed ADMM iterations.
221 pub iterations: usize,
222 /// Wall-clock time this solve was charged with: setup plus iteration for
223 /// [`Solver::solve`], iteration only for
224 /// [`Workspace::solve`](crate::Workspace::solve) (setup was paid at
225 /// workspace construction).
226 pub solve_time: Duration,
227 /// Penalty used for the final iteration.
228 pub final_rho: f64,
229 /// Number of adaptive penalty changes. Each change needs the matching
230 /// reduced factorization: one-shot solves rebuild it, workspace solves
231 /// reuse a cached one when the penalty was already visited.
232 pub rho_updates: usize,
233 /// Whether the returned iterate is the polished one: `true` only when
234 /// the status is [`SolveStatus::Solved`], [`SolverSettings::polish`] is
235 /// enabled, and the direct active-set solve improved the worst KKT
236 /// residual (otherwise the ADMM iterate is kept and this stays `false`).
237 pub polished: bool,
238 /// Heuristic hints; present only when the status is not `Solved`.
239 pub diagnostics: Option<ConvergenceDiagnostics>,
240 /// Infeasibility proof; present only when the status is
241 /// [`SolveStatus::PrimalInfeasible`] or [`SolveStatus::DualInfeasible`].
242 ///
243 /// Certificates are normalized to unit infinity norm, reported in the
244 /// original data space, and independently auditable with
245 /// [`crate::check_primal_certificate`] /
246 /// [`crate::check_dual_certificate`].
247 pub certificate: Option<Certificate>,
248}
249
250impl Solution {
251 /// Converts this solution into a full warm start for a related solve.
252 #[must_use]
253 pub fn warm_start(&self) -> WarmStart {
254 WarmStart {
255 x: self.x.clone(),
256 equality_dual: Some(self.dual.equalities.clone()),
257 inequality_dual: Some(self.dual.inequalities.clone()),
258 bound_dual: Some(self.dual.bounds.clone()),
259 l1_dual: Some(self.dual.l1.clone()),
260 }
261 }
262}
263
264/// Errors that prevent solver setup.
265#[derive(Debug, Error)]
266pub enum SolverError {
267 /// Invalid QP data.
268 #[error(transparent)]
269 InvalidProblem(#[from] ProblemError),
270 /// Invalid scalar solver setting.
271 #[error("invalid solver setting: {0}")]
272 InvalidSettings(&'static str),
273 /// Dense factor covariance is not positive semidefinite.
274 #[error("dense factor covariance is not positive semidefinite")]
275 NonPositiveSemidefiniteOmega,
276 /// A reduced positive-definite system could not be factored.
277 #[error("failed to factor the reduced linear system")]
278 LinearSystem,
279 /// Internal KKT diagnostic dimensions were inconsistent.
280 #[error(transparent)]
281 Kkt(#[from] KktError),
282 /// A warm-start vector has the wrong length.
283 #[error("warm-start {field} has length {actual}; expected {expected}")]
284 WarmStartDimension {
285 /// Warm-start field.
286 field: &'static str,
287 /// Expected vector length.
288 expected: usize,
289 /// Supplied vector length.
290 actual: usize,
291 },
292 /// A warm-start vector contains NaN or infinity.
293 #[error("warm-start {0} contains a non-finite value")]
294 WarmStartNonFinite(&'static str),
295}
296
297/// Structure-exploiting convex QP solver.
298#[derive(Clone, Debug, Default)]
299pub struct Solver {
300 settings: SolverSettings,
301}
302
303impl Solver {
304 /// Creates a solver with explicit settings.
305 #[must_use]
306 pub const fn new(settings: SolverSettings) -> Self {
307 Self { settings }
308 }
309
310 /// Returns the active settings.
311 #[must_use]
312 pub const fn settings(&self) -> &SolverSettings {
313 &self.settings
314 }
315
316 /// Solves a convex factor-model QP.
317 ///
318 /// When [`SolverSettings::scaling_iterations`] is positive, ADMM iterates
319 /// on a Ruiz-equilibrated copy of the data; termination checks and every
320 /// value on the returned [`Solution`] are evaluated on the original data.
321 ///
322 /// Each call pays equilibration and factorization setup again. For
323 /// rolling sequences over a fixed structure, build a [`Workspace`] with
324 /// [`Solver::workspace`] instead.
325 ///
326 /// # Errors
327 ///
328 /// Returns [`SolverError`] when the input, settings, warm start, factor
329 /// covariance, or reduced linear system is invalid.
330 pub fn solve(
331 &self,
332 problem: &QpProblem,
333 warm_start: Option<&WarmStart>,
334 ) -> Result<Solution, SolverError> {
335 let started = Instant::now();
336 let mut workspace = Workspace::new(&self.settings, problem)?;
337 workspace.solve_from(started, warm_start)
338 }
339
340 /// Builds a reusable [`Workspace`] that caches the equilibration and the
341 /// SMW-reduced factorization across solves (roadmap 2.4).
342 ///
343 /// Use it when the problem structure — covariance, constraint matrices,
344 /// bounds — is fixed and only the linear cost or right-hand sides change
345 /// between solves, as in rolling rebalances.
346 ///
347 /// # Errors
348 ///
349 /// Returns [`SolverError`] when the input, settings, factor covariance,
350 /// or reduced linear system is invalid.
351 pub fn workspace(&self, problem: &QpProblem) -> Result<Workspace, SolverError> {
352 Workspace::new(&self.settings, problem)
353 }
354}
355
356/// Validates scalar solver settings shared by every solve path.
357pub(crate) fn validate_settings(settings: &SolverSettings) -> Result<(), SolverError> {
358 if settings.max_iterations == 0 {
359 return Err(SolverError::InvalidSettings(
360 "max_iterations must be positive",
361 ));
362 }
363 if !settings.absolute_tolerance.is_finite() || settings.absolute_tolerance <= 0.0 {
364 return Err(SolverError::InvalidSettings(
365 "absolute_tolerance must be finite and positive",
366 ));
367 }
368 if !settings.relative_tolerance.is_finite() || settings.relative_tolerance < 0.0 {
369 return Err(SolverError::InvalidSettings(
370 "relative_tolerance must be finite and non-negative",
371 ));
372 }
373 if !settings.rho.is_finite() || settings.rho <= 0.0 {
374 return Err(SolverError::InvalidSettings(
375 "rho must be finite and positive",
376 ));
377 }
378 if !settings.sigma.is_finite() || settings.sigma <= 0.0 {
379 return Err(SolverError::InvalidSettings(
380 "sigma must be finite and positive",
381 ));
382 }
383 if settings.check_termination_every == 0 {
384 return Err(SolverError::InvalidSettings(
385 "check_termination_every must be positive",
386 ));
387 }
388 if settings.adaptive_rho_interval == 0 {
389 return Err(SolverError::InvalidSettings(
390 "adaptive_rho_interval must be positive",
391 ));
392 }
393 if !settings.adaptive_rho_tolerance.is_finite() || settings.adaptive_rho_tolerance <= 1.0 {
394 return Err(SolverError::InvalidSettings(
395 "adaptive_rho_tolerance must be finite and greater than one",
396 ));
397 }
398 if !settings.adaptive_rho_multiplier.is_finite() || settings.adaptive_rho_multiplier <= 1.0 {
399 return Err(SolverError::InvalidSettings(
400 "adaptive_rho_multiplier must be finite and greater than one",
401 ));
402 }
403 if !settings.over_relaxation.is_finite()
404 || settings.over_relaxation <= 0.0
405 || settings.over_relaxation >= 2.0
406 {
407 return Err(SolverError::InvalidSettings(
408 "over_relaxation must lie strictly between zero and two",
409 ));
410 }
411 if !settings.minimum_rho.is_finite() || settings.minimum_rho <= 0.0 {
412 return Err(SolverError::InvalidSettings(
413 "minimum_rho must be finite and positive",
414 ));
415 }
416 if !settings.maximum_rho.is_finite() || settings.maximum_rho < settings.minimum_rho {
417 return Err(SolverError::InvalidSettings(
418 "maximum_rho must be finite and at least minimum_rho",
419 ));
420 }
421 if settings.rho < settings.minimum_rho || settings.rho > settings.maximum_rho {
422 return Err(SolverError::InvalidSettings(
423 "rho must lie between minimum_rho and maximum_rho",
424 ));
425 }
426 if !settings.infeasibility_tolerance.is_finite() || settings.infeasibility_tolerance < 0.0 {
427 return Err(SolverError::InvalidSettings(
428 "infeasibility_tolerance must be finite and non-negative",
429 ));
430 }
431 if !settings.polish_regularization.is_finite() || settings.polish_regularization <= 0.0 {
432 return Err(SolverError::InvalidSettings(
433 "polish_regularization must be finite and positive",
434 ));
435 }
436 Ok(())
437}
438
439/// Benchmark-only hooks into solver internals.
440///
441/// Compiled only with the `bench-internals` feature so criterion benches can
442/// time the reduced factorization and the x-update in isolation. Not a public
443/// API; may change without notice.
444#[cfg(feature = "bench-internals")]
445#[doc(hidden)]
446pub mod bench_internals {
447 use super::{QpProblem, SolverError};
448 use crate::workspace::FactorizedSystem;
449
450 /// Opaque handle over the SMW-reduced factorized system.
451 pub struct ReducedSystem(FactorizedSystem);
452
453 /// Builds the reduced factorization used by every x-update.
454 ///
455 /// # Errors
456 ///
457 /// Returns [`SolverError`] when the factor covariance or the reduced
458 /// system cannot be factored.
459 pub fn factorize(
460 problem: &QpProblem,
461 rho: f64,
462 sigma: f64,
463 ) -> Result<ReducedSystem, SolverError> {
464 FactorizedSystem::new(problem, rho, sigma).map(ReducedSystem)
465 }
466
467 /// Applies one x-update linear solve in place.
468 pub fn x_update(system: &ReducedSystem, right_hand_side: &mut [f64]) {
469 system.0.solve_in_place(right_hand_side);
470 }
471}