Skip to main content

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}