Skip to main content

ledge_core/
kkt.rs

1//! Karush-Kuhn-Tucker residual checks.
2
3use thiserror::Error;
4
5use crate::{matrix::norm_inf, problem::QpProblem};
6
7/// Dual multipliers associated with each constraint block.
8///
9/// Inequality multipliers use the convention `A_ineq * x <= b_ineq` and are
10/// therefore non-negative. A box multiplier is negative at a lower bound and
11/// positive at an upper bound.
12#[derive(Clone, Debug, Default, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct DualVariables {
15    /// Equality multipliers.
16    pub equalities: Vec<f64>,
17    /// Upper-inequality multipliers.
18    pub inequalities: Vec<f64>,
19    /// Combined normal-cone multipliers for variable boxes.
20    pub bounds: Vec<f64>,
21    /// Subgradient multipliers of the [`L1Term`](crate::L1Term); empty when
22    /// the problem has none.
23    ///
24    /// Each entry lies in `[-costs[i], costs[i]]` and equals the signed cost
25    /// wherever the variable moved off the anchor (`+costs[i]` above it,
26    /// `-costs[i]` below).
27    pub l1: Vec<f64>,
28}
29
30/// Infinity-norm KKT diagnostics.
31#[derive(Clone, Copy, Debug, Default, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct KktResiduals {
34    /// Maximum equality, inequality, or bound violation.
35    pub primal: f64,
36    /// Maximum stationarity or dual-cone violation.
37    pub dual: f64,
38    /// Maximum absolute complementary-slackness product.
39    pub complementarity: f64,
40}
41
42/// Errors from the standalone KKT checker.
43#[derive(Clone, Debug, Error, PartialEq, Eq)]
44pub enum KktError {
45    /// A primal or dual vector has the wrong length.
46    #[error("{field} has length {actual}; expected {expected}")]
47    Dimension {
48        /// Name of the vector.
49        field: &'static str,
50        /// Expected length.
51        expected: usize,
52        /// Supplied length.
53        actual: usize,
54    },
55}
56
57/// Evaluates primal feasibility, stationarity/dual feasibility, and
58/// complementarity.
59///
60/// # Errors
61///
62/// Returns [`KktError::Dimension`] if a primal or multiplier vector has the
63/// wrong length.
64pub fn check_kkt(
65    problem: &QpProblem,
66    x: &[f64],
67    dual: &DualVariables,
68) -> Result<KktResiduals, KktError> {
69    let n = problem.quadratic.dimension();
70    let l1_expected = if problem.l1.is_some() { n } else { 0 };
71    for (field, actual, expected) in [
72        ("x", x.len(), n),
73        (
74            "dual.equalities",
75            dual.equalities.len(),
76            problem.equalities.len(),
77        ),
78        (
79            "dual.inequalities",
80            dual.inequalities.len(),
81            problem.inequalities.len(),
82        ),
83        ("dual.bounds", dual.bounds.len(), n),
84        ("dual.l1", dual.l1.len(), l1_expected),
85    ] {
86        if actual != expected {
87            return Err(KktError::Dimension {
88                field,
89                expected,
90                actual,
91            });
92        }
93    }
94
95    let equality_values = problem.equalities.matrix.mul_vec(x);
96    let inequality_values = problem.inequalities.matrix.mul_vec(x);
97    let mut primal = 0.0_f64;
98    for (value, rhs) in equality_values.iter().zip(&problem.equalities.rhs) {
99        primal = primal.max((value - rhs).abs());
100    }
101    for (value, rhs) in inequality_values.iter().zip(&problem.inequalities.rhs) {
102        primal = primal.max((value - rhs).max(0.0));
103    }
104    for (index, value) in x.iter().enumerate() {
105        primal = primal
106            .max((problem.lower_bounds[index] - value).max(0.0))
107            .max((value - problem.upper_bounds[index]).max(0.0));
108    }
109
110    let mut stationarity = problem.quadratic.apply(x);
111    for (value, linear) in stationarity.iter_mut().zip(&problem.linear) {
112        *value += linear;
113    }
114    problem
115        .equalities
116        .matrix
117        .transpose_mul_add(&dual.equalities, &mut stationarity);
118    problem
119        .inequalities
120        .matrix
121        .transpose_mul_add(&dual.inequalities, &mut stationarity);
122    for (value, bound_dual) in stationarity.iter_mut().zip(&dual.bounds) {
123        *value += bound_dual;
124    }
125    for (value, l1_dual) in stationarity.iter_mut().zip(&dual.l1) {
126        *value += l1_dual;
127    }
128
129    let mut cone_violation = 0.0_f64;
130    let mut complementarity = 0.0_f64;
131    for ((value, rhs), multiplier) in inequality_values
132        .iter()
133        .zip(&problem.inequalities.rhs)
134        .zip(&dual.inequalities)
135    {
136        cone_violation = cone_violation.max((-multiplier).max(0.0));
137        complementarity = complementarity.max((multiplier * (value - rhs)).abs());
138    }
139
140    // Box multipliers are scored without an activity window: the positive
141    // part of a multiplier must pair with a finite upper bound and the
142    // negative part with a finite lower bound (else it violates the dual
143    // cone), and each part is charged the epsilon-complementarity product
144    // `|multiplier| * distance-to-bound`. This keeps the checker fair to
145    // near-active solutions from interior-point solvers, whose multipliers
146    // decay smoothly with the distance to the bound instead of switching
147    // off at machine precision.
148    for (index, value) in x.iter().copied().enumerate() {
149        let lower = problem.lower_bounds[index];
150        let upper = problem.upper_bounds[index];
151        let multiplier = dual.bounds[index];
152        let toward_upper = multiplier.max(0.0);
153        let toward_lower = (-multiplier).max(0.0);
154        if upper.is_finite() {
155            complementarity = complementarity.max(toward_upper * (upper - value).abs());
156        } else {
157            cone_violation = cone_violation.max(toward_upper);
158        }
159        if lower.is_finite() {
160            complementarity = complementarity.max(toward_lower * (value - lower).abs());
161        } else {
162            cone_violation = cone_violation.max(toward_lower);
163        }
164    }
165
166    // L1 multipliers must lie in the subdifferential of
167    // `sum_i costs[i] * |x[i] - anchor[i]|`: inside `[-c_i, c_i]` everywhere
168    // (dual-cone violation otherwise), pinned at the signed cost wherever the
169    // variable moved off the anchor. The pinning is scored continuously,
170    // mirroring the box scoring above: the shortfall from the required signed
171    // cost is charged times the distance moved in that direction.
172    if let Some(l1) = &problem.l1 {
173        for (index, multiplier) in dual.l1.iter().copied().enumerate() {
174            let cost = l1.costs[index];
175            let offset = x[index] - l1.anchor[index];
176            cone_violation = cone_violation.max(multiplier.abs() - cost);
177            complementarity = complementarity
178                .max(((cost - multiplier) * offset.max(0.0)).abs())
179                .max(((cost + multiplier) * (-offset).max(0.0)).abs());
180        }
181    }
182
183    Ok(KktResiduals {
184        primal,
185        dual: norm_inf(&stationarity).max(cone_violation),
186        complementarity,
187    })
188}