Skip to main content

gam_problem/
linear_constraints.rs

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