Skip to main content

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}