pounce_qp/working_set.rs
1//! Working-set representation — the discrete state carried across
2//! QP solves to implement parametric warm starting.
3//!
4//! Each bound slot and each general-constraint slot has a small
5//! status enum. The pair `(bounds, constraints)` is the only piece
6//! of discrete state the QP solver hands back to the caller (and
7//! accepts back as a warm start).
8
9use crate::error::QpError;
10
11/// Status of a single primal-variable bound.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum BoundStatus {
14 /// Not in the working set; `xl < x < xu` strictly.
15 Inactive,
16 /// Active at the lower bound; `x = xl`, dual `≥ 0`.
17 AtLower,
18 /// Active at the upper bound; `x = xu`, dual `≤ 0`.
19 AtUpper,
20 /// `xl = xu`; the variable is fixed and always in the working
21 /// set with no sign constraint on the dual.
22 Fixed,
23}
24
25impl BoundStatus {
26 pub fn is_active(self) -> bool {
27 !matches!(self, BoundStatus::Inactive)
28 }
29}
30
31/// Status of a single general constraint `bl ≤ aᵀx ≤ bu`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ConsStatus {
34 /// Not in the working set; `bl < aᵀx < bu` strictly.
35 Inactive,
36 /// Active at the lower bound; `aᵀx = bl`, dual `≥ 0`.
37 AtLower,
38 /// Active at the upper bound; `aᵀx = bu`, dual `≤ 0`.
39 AtUpper,
40 /// `bl = bu`; the row is an equality and always in the working
41 /// set with no sign constraint on the dual.
42 Equality,
43}
44
45impl ConsStatus {
46 pub fn is_active(self) -> bool {
47 !matches!(self, ConsStatus::Inactive)
48 }
49}
50
51/// The working set for a QP of dimension `n` with `m` general
52/// constraints. `bounds.len() == n`, `constraints.len() == m`.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct WorkingSet {
55 pub bounds: Vec<BoundStatus>,
56 pub constraints: Vec<ConsStatus>,
57}
58
59impl WorkingSet {
60 /// All-inactive working set sized to `(n, m)`. This is the cold-
61 /// start seed handed to the phase-1 elastic-mode QP.
62 pub fn cold(n: usize, m: usize) -> Self {
63 Self {
64 bounds: vec![BoundStatus::Inactive; n],
65 constraints: vec![ConsStatus::Inactive; m],
66 }
67 }
68
69 pub fn n(&self) -> usize {
70 self.bounds.len()
71 }
72
73 pub fn m(&self) -> usize {
74 self.constraints.len()
75 }
76
77 /// Count of active bounds plus active constraints (the dimension
78 /// of the KKT block currently driving the EQP step).
79 pub fn active_count(&self) -> usize {
80 self.bounds.iter().filter(|s| s.is_active()).count()
81 + self.constraints.iter().filter(|s| s.is_active()).count()
82 }
83
84 /// Reject working sets whose dimensions disagree with the
85 /// problem they will be applied to. Called by the solver before
86 /// consuming a user-supplied warm start.
87 pub fn validate_dims(&self, n: usize, m: usize) -> Result<(), QpError> {
88 if self.bounds.len() != n {
89 return Err(QpError::WarmStartDimensionMismatch(format!(
90 "bounds.len() = {} but problem n = {n}",
91 self.bounds.len()
92 )));
93 }
94 if self.constraints.len() != m {
95 return Err(QpError::WarmStartDimensionMismatch(format!(
96 "constraints.len() = {} but problem m = {m}",
97 self.constraints.len()
98 )));
99 }
100 Ok(())
101 }
102}