1use crate::{
21 kkt::{DualVariables, KktError},
22 matrix::{dot, norm_inf},
23 problem::QpProblem,
24};
25
26const DIVISION_GUARD: f64 = 1.0e-12;
29
30#[derive(Clone, Debug, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum Certificate {
38 Primal(PrimalCertificate),
40 Dual(DualCertificate),
42}
43
44#[derive(Clone, Debug, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct PrimalCertificate {
63 pub equality_dual: Vec<f64>,
65 pub inequality_dual: Vec<f64>,
67 pub bound_dual: Vec<f64>,
70}
71
72#[derive(Clone, Debug, PartialEq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct DualCertificate {
84 pub direction: Vec<f64>,
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct PrimalCertificateResiduals {
96 pub stationarity: f64,
98 pub support_gap: f64,
101 pub cone_violation: f64,
104}
105
106#[derive(Clone, Copy, Debug, Default, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct DualCertificateResiduals {
114 pub curvature: f64,
116 pub objective_gap: f64,
120 pub recession_violation: f64,
124}
125
126pub 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
205pub 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 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
257pub(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
326pub(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 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 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 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 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 assert!(detect_dual_infeasibility(&problem, &[3.0, 0.0], 1.0e-5).is_none());
455 assert!(detect_dual_infeasibility(&problem, &[-3.0, -1.0], 1.0e-5).is_none());
457 }
458}