Skip to main content

gam_problem/
linear_constraints.rs

1use ndarray::{Array1, Array2};
2use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Debug, Serialize, Deserialize)]
5pub struct LinearInequalityConstraints {
6    pub a: Array2<f64>,
7    pub b: Array1<f64>,
8}
9
10impl LinearInequalityConstraints {
11    /// Construct with the equal-row-count invariant enforced. The dimensions
12    /// `a.nrows() == b.len()` are required by every downstream KKT / active-set
13    /// routine; routing every construction site through this constructor
14    /// eliminates a class of "rows out of sync" bugs at the type boundary.
15    #[inline]
16    pub fn new(a: Array2<f64>, b: Array1<f64>) -> Result<Self, String> {
17        if a.nrows() != b.len() {
18            return Err(format!(
19                "LinearInequalityConstraints: row count mismatch (A has {} rows, b has length {})",
20                a.nrows(),
21                b.len(),
22            ));
23        }
24        if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
25            return Err(
26                "LinearInequalityConstraints: A and b must be finite (a NaN row silently \
27                 evades every feasibility comparison downstream)"
28                    .to_string(),
29            );
30        }
31        Ok(Self { a, b })
32    }
33
34    /// Canonicalize the system `Aβ ≥ b` into the scale-free form every
35    /// downstream tolerance assumes, PRESERVING row count and order (so cached
36    /// active-set row indices and warm-start hints remain valid):
37    ///
38    /// * non-finite entries are rejected (a NaN row compares as neither
39    ///   feasible nor infeasible and silently evades active-set logic);
40    /// * an exactly-zero row `0ᵀβ ≥ b_i` is INFEASIBLE for `b_i > 0`
41    ///   (rejected loudly); for `b_i ≤ 0` it is vacuous and kept verbatim —
42    ///   its geometric slack is `+∞`, so it can never activate downstream;
43    /// * every nonzero row is normalized to unit norm, `(a_i, b_i)/‖a_i‖`, so
44    ///   `b_i` becomes the signed distance of the constraint hyperplane from
45    ///   the origin and every absolute slack / violation / rank tolerance
46    ///   applied later is automatically scale-relative: `1e-20·β ≥ 1e-20` and
47    ///   `β ≥ 1` canonicalize to the same row, as they are the same
48    ///   half-space.
49    pub fn canonicalized(&self) -> Result<Self, String> {
50        let m = self.a.nrows();
51        if self.b.len() != m {
52            return Err(format!(
53                "LinearInequalityConstraints: row count mismatch (A has {m} rows, b has length {})",
54                self.b.len(),
55            ));
56        }
57        if self.a.iter().any(|v| !v.is_finite()) || self.b.iter().any(|v| !v.is_finite()) {
58            return Err("LinearInequalityConstraints: A and b must be finite".to_string());
59        }
60        let mut a = self.a.clone();
61        let mut b = self.b.clone();
62        for i in 0..m {
63            let norm = a.row(i).dot(&a.row(i)).sqrt();
64            if norm > 0.0 {
65                a.row_mut(i).mapv_inplace(|v| v / norm);
66                b[i] /= norm;
67            } else if b[i] > 0.0 {
68                return Err(format!(
69                    "LinearInequalityConstraints: row {i} is zero with positive bound \
70                     b = {:.3e}; the constraint 0ᵀβ ≥ b is infeasible",
71                    b[i],
72                ));
73            }
74        }
75        Ok(Self { a, b })
76    }
77
78    /// Build the per-coordinate `β_i >= lower_bounds[i]` inequality system.
79    /// Non-finite entries are treated as "no bound" and skipped; returns
80    /// `None` when every entry is non-finite so callers can short-circuit
81    /// the no-constraint case without allocating the empty A/b pair.
82    pub fn from_per_coordinate_lower_bounds(lower_bounds: &Array1<f64>) -> Option<Self> {
83        let active_rows: Vec<usize> = (0..lower_bounds.len())
84            .filter(|&i| lower_bounds[i].is_finite())
85            .collect();
86        if active_rows.is_empty() {
87            return None;
88        }
89        let p = lower_bounds.len();
90        let mut a = Array2::<f64>::zeros((active_rows.len(), p));
91        let mut b = Array1::<f64>::zeros(active_rows.len());
92        for (r, &idx) in active_rows.iter().enumerate() {
93            a[[r, idx]] = 1.0;
94            b[r] = lower_bounds[idx];
95        }
96        Some(Self { a, b })
97    }
98
99    /// The contract-feasible ratio test on the dense system — the identical
100    /// rule `crate::ConstraintSet::max_contract_feasible_step` applies,
101    /// reached without wrapping (and therefore cloning) `A` into a
102    /// [`crate::ConstraintSet`].
103    ///
104    /// A dense system a caller already holds and the `ConstraintSet::Dense`
105    /// that wraps it must give bit-identical answers, or the barrier hook and
106    /// the QP that enforce the same constraint would disagree about which
107    /// steps are admissible.
108    pub fn max_contract_feasible_step(
109        &self,
110        beta: ndarray::ArrayView1<'_, f64>,
111        direction: ndarray::ArrayView1<'_, f64>,
112    ) -> Result<crate::ContractFeasibleStep, crate::ContractFeasibleStepError> {
113        if beta.len() != self.a.ncols() || direction.len() != self.a.ncols() {
114            return Err(crate::ContractFeasibleStepError::Dimension {
115                beta: beta.len(),
116                direction: direction.len(),
117                expected: self.a.ncols(),
118            });
119        }
120        let values = self.a.dot(&beta);
121        let directional = self.a.dot(&direction);
122        crate::constraint_set::contract_feasible_step_over_rows(
123            &values,
124            &directional,
125            |row| Ok(self.b[row]),
126            |row| {
127                let r = self.a.row(row);
128                Ok(r.dot(&r).sqrt())
129            },
130        )
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use ndarray::{Array1, Array2, array};
138
139    #[test]
140    fn new_ok_when_rows_match_b_len() {
141        let a = Array2::<f64>::eye(3);
142        let b = Array1::<f64>::zeros(3);
143        assert!(LinearInequalityConstraints::new(a, b).is_ok());
144    }
145
146    #[test]
147    fn new_err_on_row_count_mismatch() {
148        let a = Array2::<f64>::eye(3);
149        let b = Array1::<f64>::zeros(2);
150        assert!(LinearInequalityConstraints::new(a, b).is_err());
151    }
152
153    #[test]
154    fn from_lower_bounds_none_when_all_non_finite() {
155        let bounds = array![f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
156        assert!(LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).is_none());
157    }
158
159    #[test]
160    fn from_lower_bounds_selects_finite_entries() {
161        // bounds = [NaN, 1.0, NaN] → one active constraint: β₁ ≥ 1.0
162        let bounds = array![f64::NAN, 1.0_f64, f64::NAN];
163        let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
164        assert_eq!(c.a.nrows(), 1);
165        assert_eq!(c.a.ncols(), 3);
166        assert_eq!(c.a[[0, 1]], 1.0);
167        assert_eq!(c.b[0], 1.0);
168    }
169
170    #[test]
171    fn canonicalized_is_invariant_to_row_rescaling() {
172        // 1e-20·β ≥ 1e-20 is the same half-space as β ≥ 1 and must canonicalize
173        // to the identical unit row.
174        let tiny = LinearInequalityConstraints {
175            a: array![[1e-20_f64]],
176            b: array![1e-20_f64],
177        }
178        .canonicalized()
179        .unwrap();
180        assert!((tiny.a[[0, 0]] - 1.0).abs() < 1e-15);
181        assert!((tiny.b[0] - 1.0).abs() < 1e-15);
182    }
183
184    #[test]
185    fn canonicalized_rejects_infeasible_zero_row() {
186        // 0·β ≥ 1 is impossible and must fail loudly, not vanish.
187        let c = LinearInequalityConstraints {
188            a: array![[0.0_f64, 0.0]],
189            b: array![1.0_f64],
190        };
191        assert!(c.canonicalized().is_err());
192    }
193
194    #[test]
195    fn canonicalized_keeps_row_indices_and_normalizes_nonzero_rows() {
196        // Row order/count are preserved (warm active-set ids stay valid); the
197        // vacuous zero row is kept verbatim, the real row is unit-normalized.
198        let c = LinearInequalityConstraints {
199            a: array![[0.0_f64, 0.0], [3.0, 4.0]],
200            b: array![-2.0_f64, 10.0],
201        };
202        let canon = c.canonicalized().unwrap();
203        assert_eq!(canon.a.nrows(), 2);
204        assert_eq!(canon.a[[0, 0]], 0.0);
205        assert_eq!(canon.b[0], -2.0);
206        assert!((canon.a[[1, 0]] - 0.6).abs() < 1e-15);
207        assert!((canon.a[[1, 1]] - 0.8).abs() < 1e-15);
208        assert!((canon.b[1] - 2.0).abs() < 1e-15);
209    }
210
211    #[test]
212    fn canonicalized_rejects_nan_rows() {
213        let c = LinearInequalityConstraints {
214            a: array![[f64::NAN, 0.0]],
215            b: array![0.0_f64],
216        };
217        assert!(c.canonicalized().is_err());
218    }
219
220    #[test]
221    fn new_rejects_non_finite_entries() {
222        let a = array![[f64::NAN]];
223        let b = array![0.0_f64];
224        assert!(LinearInequalityConstraints::new(a, b).is_err());
225    }
226
227    #[test]
228    fn from_lower_bounds_multiple_active_rows() {
229        let bounds = array![0.5_f64, f64::NAN, -1.0];
230        let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
231        assert_eq!(c.a.nrows(), 2);
232        // First row: col 0 active with bound 0.5
233        assert_eq!(c.a[[0, 0]], 1.0);
234        assert_eq!(c.b[0], 0.5);
235        // Second row: col 2 active with bound -1.0
236        assert_eq!(c.a[[1, 2]], 1.0);
237        assert_eq!(c.b[1], -1.0);
238    }
239}