Skip to main content

ledge_core/
certificate.rs

1//! Infeasibility certificates (roadmap 2.2).
2//!
3//! On infeasible problems, ADMM iterate *differences* converge to certificate
4//! directions (OSQP-style detection): the dual differences to a Farkas
5//! certificate of primal infeasibility, the primal differences to an
6//! unbounded descent direction proving dual infeasibility. The solver checks
7//! both on the termination cadence and stops with
8//! [`SolveStatus::PrimalInfeasible`](crate::SolveStatus::PrimalInfeasible) or
9//! [`SolveStatus::DualInfeasible`](crate::SolveStatus::DualInfeasible),
10//! attaching the normalized certificate to
11//! [`Solution::certificate`](crate::Solution).
12//!
13//! Certificates follow the same auditability rule as every other reported
14//! quantity: they are detected and reported **in the original data space**
15//! (never the equilibrated copy), and the standalone checkers
16//! [`check_primal_certificate`] / [`check_dual_certificate`] recompute their
17//! defining residuals independently, exactly like [`crate::check_kkt`] does
18//! for solutions.
19
20use crate::{
21    kkt::{DualVariables, KktError},
22    matrix::{dot, norm_inf},
23    problem::QpProblem,
24};
25
26/// Directions smaller than this are considered numerical silence, not
27/// candidate certificates.
28const DIVISION_GUARD: f64 = 1.0e-12;
29
30/// Proof that a solve stopped because the problem itself is pathological.
31///
32/// Attached to [`Solution::certificate`](crate::Solution) when the status is
33/// [`SolveStatus::PrimalInfeasible`](crate::SolveStatus::PrimalInfeasible) or
34/// [`SolveStatus::DualInfeasible`](crate::SolveStatus::DualInfeasible).
35#[derive(Clone, Debug, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum Certificate {
38    /// The constraints admit no common point.
39    Primal(PrimalCertificate),
40    /// The objective is unbounded below over the constraints.
41    Dual(DualCertificate),
42}
43
44/// A Farkas certificate of primal infeasibility, normalized to unit
45/// infinity norm.
46///
47/// The multipliers prove that no `x` can satisfy
48/// `A_e x = b_e`, `A_i x <= b_i`, `l <= x <= u` simultaneously: with
49/// `y_i >= 0`, positive bound parts supported on finite upper bounds, and
50/// negative bound parts supported on finite lower bounds, any feasible `x`
51/// would give
52///
53/// ```text
54/// (A_e' y_e + A_i' y_i + y_b)' x <= b_e' y_e + b_i' y_i + u'(y_b)+ + l'(y_b)-
55/// ```
56///
57/// A valid certificate makes the left side (approximately) zero for every `x`
58/// while the right side — the *support gap* — is strictly negative:
59/// a contradiction. Verify with [`check_primal_certificate`].
60#[derive(Clone, Debug, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct PrimalCertificate {
63    /// Weights on the equality rows (free sign).
64    pub equality_dual: Vec<f64>,
65    /// Weights on the inequality rows (non-negative).
66    pub inequality_dual: Vec<f64>,
67    /// Weights on the variable boxes: positive parts cite upper bounds,
68    /// negative parts cite lower bounds.
69    pub bound_dual: Vec<f64>,
70}
71
72/// An unbounded-descent certificate of dual infeasibility, normalized to
73/// unit infinity norm.
74///
75/// The direction `v` proves the objective decreases without bound: `Q v ~ 0`
76/// (no quadratic cost along the ray), `q' v < 0` (the linear cost strictly
77/// decreases), and `v` is a recession direction of the constraints
78/// (`A_e v ~ 0`, `A_i v <~ 0`, non-positive where an upper bound is finite,
79/// non-negative where a lower bound is finite). Verify with
80/// [`check_dual_certificate`].
81#[derive(Clone, Debug, PartialEq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct DualCertificate {
84    /// The unbounded descent direction in the decision space.
85    pub direction: Vec<f64>,
86}
87
88/// Independently recomputed residuals of a [`PrimalCertificate`].
89///
90/// The certificate is valid to tolerance `eps` when `stationarity <= eps`,
91/// `cone_violation <= eps`, and `support_gap <= -eps` (for a certificate
92/// normalized to unit infinity norm).
93#[derive(Clone, Copy, Debug, Default, PartialEq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct PrimalCertificateResiduals {
96    /// `||A_e' y_e + A_i' y_i + y_b||_inf`; zero for an exact certificate.
97    pub stationarity: f64,
98    /// `b_e' y_e + b_i' y_i + u'(y_b)+ + l'(y_b)-` over finite bounds;
99    /// strictly negative for a valid certificate.
100    pub support_gap: f64,
101    /// Largest violation of the certificate cone: negative inequality
102    /// weights, or bound weights citing an infinite bound.
103    pub cone_violation: f64,
104}
105
106/// Independently recomputed residuals of a [`DualCertificate`].
107///
108/// The certificate is valid to tolerance `eps` when `curvature <= eps`,
109/// `recession_violation <= eps`, and `objective_gap <= -eps` (for a
110/// direction normalized to unit infinity norm).
111#[derive(Clone, Copy, Debug, Default, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct DualCertificateResiduals {
114    /// `||Q v||_inf`; zero when the ray carries no quadratic cost.
115    pub curvature: f64,
116    /// `q' v` plus, when the problem has an [`L1Term`](crate::L1Term), its
117    /// recession slope `sum_i costs[i] * |v_i|`; strictly negative when the
118    /// full objective descends along the ray.
119    pub objective_gap: f64,
120    /// Largest violation of the constraint recession cone: `|A_e v|`,
121    /// positive parts of `A_i v`, positive components where an upper bound
122    /// is finite, negative components where a lower bound is finite.
123    pub recession_violation: f64,
124}
125
126/// Recomputes the defining residuals of a primal infeasibility certificate
127/// on the original problem data.
128///
129/// # Errors
130///
131/// Returns [`KktError::Dimension`] when a multiplier block has the wrong
132/// length.
133pub fn check_primal_certificate(
134    problem: &QpProblem,
135    certificate: &PrimalCertificate,
136) -> Result<PrimalCertificateResiduals, KktError> {
137    let n = problem.quadratic.dimension();
138    for (field, actual, expected) in [
139        (
140            "certificate.equality_dual",
141            certificate.equality_dual.len(),
142            problem.equalities.len(),
143        ),
144        (
145            "certificate.inequality_dual",
146            certificate.inequality_dual.len(),
147            problem.inequalities.len(),
148        ),
149        ("certificate.bound_dual", certificate.bound_dual.len(), n),
150    ] {
151        if actual != expected {
152            return Err(KktError::Dimension {
153                field,
154                expected,
155                actual,
156            });
157        }
158    }
159
160    let mut combination = certificate.bound_dual.clone();
161    problem
162        .equalities
163        .matrix
164        .transpose_mul_add(&certificate.equality_dual, &mut combination);
165    problem
166        .inequalities
167        .matrix
168        .transpose_mul_add(&certificate.inequality_dual, &mut combination);
169    let stationarity = norm_inf(&combination);
170
171    let mut cone_violation = 0.0_f64;
172    let mut support_gap = dot(&problem.equalities.rhs, &certificate.equality_dual);
173    for (multiplier, rhs) in certificate
174        .inequality_dual
175        .iter()
176        .zip(&problem.inequalities.rhs)
177    {
178        cone_violation = cone_violation.max(-multiplier);
179        support_gap += rhs * multiplier.max(0.0);
180    }
181    for (index, multiplier) in certificate.bound_dual.iter().enumerate() {
182        let toward_upper = multiplier.max(0.0);
183        let toward_lower = multiplier.min(0.0);
184        let upper = problem.upper_bounds[index];
185        let lower = problem.lower_bounds[index];
186        if upper.is_finite() {
187            support_gap += upper * toward_upper;
188        } else {
189            cone_violation = cone_violation.max(toward_upper);
190        }
191        if lower.is_finite() {
192            support_gap += lower * toward_lower;
193        } else {
194            cone_violation = cone_violation.max(-toward_lower);
195        }
196    }
197
198    Ok(PrimalCertificateResiduals {
199        stationarity,
200        support_gap,
201        cone_violation,
202    })
203}
204
205/// Recomputes the defining residuals of a dual infeasibility certificate on
206/// the original problem data.
207///
208/// # Errors
209///
210/// Returns [`KktError::Dimension`] when the direction has the wrong length.
211pub fn check_dual_certificate(
212    problem: &QpProblem,
213    certificate: &DualCertificate,
214) -> Result<DualCertificateResiduals, KktError> {
215    let n = problem.quadratic.dimension();
216    if certificate.direction.len() != n {
217        return Err(KktError::Dimension {
218            field: "certificate.direction",
219            expected: n,
220            actual: certificate.direction.len(),
221        });
222    }
223
224    let curvature = norm_inf(&problem.quadratic.apply(&certificate.direction));
225    // Along the ray `t * v`, the L1 term grows like `t * sum_i c_i |v_i|`
226    // (its recession function); a valid descent ray must outrun it.
227    let l1_slope = problem.l1.as_ref().map_or(0.0, |term| {
228        term.costs
229            .iter()
230            .zip(&certificate.direction)
231            .map(|(cost, value)| cost * value.abs())
232            .sum()
233    });
234    let objective_gap = dot(&problem.linear, &certificate.direction) + l1_slope;
235
236    let mut recession_violation =
237        norm_inf(&problem.equalities.matrix.mul_vec(&certificate.direction));
238    for value in problem.inequalities.matrix.mul_vec(&certificate.direction) {
239        recession_violation = recession_violation.max(value);
240    }
241    for (index, value) in certificate.direction.iter().enumerate() {
242        if problem.upper_bounds[index].is_finite() {
243            recession_violation = recession_violation.max(*value);
244        }
245        if problem.lower_bounds[index].is_finite() {
246            recession_violation = recession_violation.max(-*value);
247        }
248    }
249
250    Ok(DualCertificateResiduals {
251        curvature,
252        objective_gap,
253        recession_violation,
254    })
255}
256
257/// Tests whether a dual-iterate difference is a primal infeasibility
258/// certificate to the given tolerance; returns the normalized certificate
259/// when it is.
260///
261/// The difference must be supplied in the original (unscaled) space. The
262/// candidate is normalized to unit infinity norm; cone noise up to the
263/// tolerance is clamped to zero so the returned certificate satisfies the
264/// sign constraints exactly, while larger violations disqualify the
265/// direction. Acceptance re-uses [`check_primal_certificate`], so a returned
266/// certificate always passes its own audit.
267pub(crate) fn detect_primal_infeasibility(
268    problem: &QpProblem,
269    delta_dual: &DualVariables,
270    tolerance: f64,
271) -> Option<PrimalCertificate> {
272    let magnitude = norm_inf(&delta_dual.equalities)
273        .max(norm_inf(&delta_dual.inequalities))
274        .max(norm_inf(&delta_dual.bounds));
275    if !magnitude.is_finite() || magnitude <= DIVISION_GUARD {
276        return None;
277    }
278
279    let equality_dual: Vec<f64> = delta_dual
280        .equalities
281        .iter()
282        .map(|value| value / magnitude)
283        .collect();
284    let mut inequality_dual: Vec<f64> = delta_dual
285        .inequalities
286        .iter()
287        .map(|value| value / magnitude)
288        .collect();
289    let mut bound_dual: Vec<f64> = delta_dual
290        .bounds
291        .iter()
292        .map(|value| value / magnitude)
293        .collect();
294
295    for value in &mut inequality_dual {
296        if *value < -tolerance {
297            return None;
298        }
299        *value = value.max(0.0);
300    }
301    for (index, value) in bound_dual.iter_mut().enumerate() {
302        if !problem.upper_bounds[index].is_finite() {
303            if *value > tolerance {
304                return None;
305            }
306            *value = value.min(0.0);
307        }
308        if !problem.lower_bounds[index].is_finite() {
309            if *value < -tolerance {
310                return None;
311            }
312            *value = value.max(0.0);
313        }
314    }
315
316    let certificate = PrimalCertificate {
317        equality_dual,
318        inequality_dual,
319        bound_dual,
320    };
321    let residuals = check_primal_certificate(problem, &certificate).ok()?;
322    (residuals.stationarity <= tolerance && residuals.support_gap <= -tolerance)
323        .then_some(certificate)
324}
325
326/// Tests whether a primal-iterate difference is a dual infeasibility
327/// certificate to the given tolerance; returns the normalized certificate
328/// when it is.
329///
330/// The difference must be supplied in the original (unscaled) space.
331/// Acceptance re-uses [`check_dual_certificate`], so a returned certificate
332/// always passes its own audit.
333pub(crate) fn detect_dual_infeasibility(
334    problem: &QpProblem,
335    delta_x: &[f64],
336    tolerance: f64,
337) -> Option<DualCertificate> {
338    let magnitude = norm_inf(delta_x);
339    if !magnitude.is_finite() || magnitude <= DIVISION_GUARD {
340        return None;
341    }
342    let certificate = DualCertificate {
343        direction: delta_x.iter().map(|value| value / magnitude).collect(),
344    };
345    let residuals = check_dual_certificate(problem, &certificate).ok()?;
346    (residuals.curvature <= tolerance
347        && residuals.recession_violation <= tolerance
348        && residuals.objective_gap <= -tolerance)
349        .then_some(certificate)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::{
355        check_dual_certificate, check_primal_certificate, detect_dual_infeasibility,
356        detect_primal_infeasibility, PrimalCertificate,
357    };
358    use crate::{
359        kkt::DualVariables,
360        problem::{FactorCovariance, FactorQuad, LinearConstraints, QpProblem},
361        Matrix,
362    };
363
364    /// `sum(x) = 1` against upper bounds allowing at most `0.8`.
365    fn budget_versus_boxes() -> QpProblem {
366        let n = 4;
367        QpProblem {
368            quadratic: FactorQuad {
369                factors: Matrix::zeros(n, 1),
370                omega: FactorCovariance::Diagonal(vec![1.0]),
371                diagonal: vec![1.0; n],
372            },
373            linear: vec![0.0; n],
374            l1: None,
375            equalities: LinearConstraints {
376                matrix: Matrix::new(1, n, vec![1.0; n]).unwrap(),
377                rhs: vec![1.0],
378            },
379            inequalities: LinearConstraints::empty(n),
380            lower_bounds: vec![0.0; n],
381            upper_bounds: vec![0.2; n],
382        }
383    }
384
385    #[test]
386    fn exact_farkas_certificate_audits_clean() {
387        let problem = budget_versus_boxes();
388        let certificate = PrimalCertificate {
389            equality_dual: vec![-1.0],
390            inequality_dual: Vec::new(),
391            bound_dual: vec![1.0; 4],
392        };
393        let residuals = check_primal_certificate(&problem, &certificate).unwrap();
394        assert!(residuals.stationarity <= 1.0e-12);
395        assert!(residuals.cone_violation <= 1.0e-12);
396        assert!((residuals.support_gap - (-0.2)).abs() <= 1.0e-12);
397    }
398
399    #[test]
400    fn detection_accepts_the_farkas_direction_and_rejects_noise() {
401        let problem = budget_versus_boxes();
402        let farkas = DualVariables {
403            equalities: vec![-2.0],
404            inequalities: Vec::new(),
405            bounds: vec![2.0; 4],
406            l1: Vec::new(),
407        };
408        let certificate = detect_primal_infeasibility(&problem, &farkas, 1.0e-5).unwrap();
409        let residuals = check_primal_certificate(&problem, &certificate).unwrap();
410        assert!(residuals.support_gap <= -1.0e-5);
411
412        // A direction with a large stationarity residual must be rejected.
413        let noise = DualVariables {
414            equalities: vec![1.0],
415            inequalities: Vec::new(),
416            bounds: vec![1.0; 4],
417            l1: Vec::new(),
418        };
419        assert!(detect_primal_infeasibility(&problem, &noise, 1.0e-5).is_none());
420
421        // Numerical silence is never a certificate.
422        let silence = DualVariables {
423            equalities: vec![0.0],
424            inequalities: Vec::new(),
425            bounds: vec![0.0; 4],
426            l1: Vec::new(),
427        };
428        assert!(detect_primal_infeasibility(&problem, &silence, 1.0e-5).is_none());
429    }
430
431    #[test]
432    fn dual_certificate_requires_descent_and_recession() {
433        // One riskless unbounded variable with a positive linear cost slope.
434        let problem = QpProblem {
435            quadratic: FactorQuad {
436                factors: Matrix::zeros(2, 1),
437                omega: FactorCovariance::Diagonal(vec![1.0]),
438                diagonal: vec![0.0, 1.0],
439            },
440            linear: vec![1.0, 0.0],
441            l1: None,
442            equalities: LinearConstraints::empty(2),
443            inequalities: LinearConstraints::empty(2),
444            lower_bounds: vec![f64::NEG_INFINITY, 0.0],
445            upper_bounds: vec![f64::INFINITY, 1.0],
446        };
447        let descent = detect_dual_infeasibility(&problem, &[-3.0, 0.0], 1.0e-5).unwrap();
448        let residuals = check_dual_certificate(&problem, &descent).unwrap();
449        assert!(residuals.curvature <= 1.0e-12);
450        assert!(residuals.objective_gap <= -1.0);
451        assert!(residuals.recession_violation <= 1.0e-12);
452
453        // Ascent along the same ray is not a certificate.
454        assert!(detect_dual_infeasibility(&problem, &[3.0, 0.0], 1.0e-5).is_none());
455        // A direction moving the bounded variable violates recession.
456        assert!(detect_dual_infeasibility(&problem, &[-3.0, -1.0], 1.0e-5).is_none());
457    }
458}