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
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use ndarray::{Array1, Array2, array};
104
105    #[test]
106    fn new_ok_when_rows_match_b_len() {
107        let a = Array2::<f64>::eye(3);
108        let b = Array1::<f64>::zeros(3);
109        assert!(LinearInequalityConstraints::new(a, b).is_ok());
110    }
111
112    #[test]
113    fn new_err_on_row_count_mismatch() {
114        let a = Array2::<f64>::eye(3);
115        let b = Array1::<f64>::zeros(2);
116        assert!(LinearInequalityConstraints::new(a, b).is_err());
117    }
118
119    #[test]
120    fn from_lower_bounds_none_when_all_non_finite() {
121        let bounds = array![f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
122        assert!(LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).is_none());
123    }
124
125    #[test]
126    fn from_lower_bounds_selects_finite_entries() {
127        // bounds = [NaN, 1.0, NaN] → one active constraint: β₁ ≥ 1.0
128        let bounds = array![f64::NAN, 1.0_f64, f64::NAN];
129        let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
130        assert_eq!(c.a.nrows(), 1);
131        assert_eq!(c.a.ncols(), 3);
132        assert_eq!(c.a[[0, 1]], 1.0);
133        assert_eq!(c.b[0], 1.0);
134    }
135
136    #[test]
137    fn canonicalized_is_invariant_to_row_rescaling() {
138        // 1e-20·β ≥ 1e-20 is the same half-space as β ≥ 1 and must canonicalize
139        // to the identical unit row.
140        let tiny = LinearInequalityConstraints {
141            a: array![[1e-20_f64]],
142            b: array![1e-20_f64],
143        }
144        .canonicalized()
145        .unwrap();
146        assert!((tiny.a[[0, 0]] - 1.0).abs() < 1e-15);
147        assert!((tiny.b[0] - 1.0).abs() < 1e-15);
148    }
149
150    #[test]
151    fn canonicalized_rejects_infeasible_zero_row() {
152        // 0·β ≥ 1 is impossible and must fail loudly, not vanish.
153        let c = LinearInequalityConstraints {
154            a: array![[0.0_f64, 0.0]],
155            b: array![1.0_f64],
156        };
157        assert!(c.canonicalized().is_err());
158    }
159
160    #[test]
161    fn canonicalized_keeps_row_indices_and_normalizes_nonzero_rows() {
162        // Row order/count are preserved (warm active-set ids stay valid); the
163        // vacuous zero row is kept verbatim, the real row is unit-normalized.
164        let c = LinearInequalityConstraints {
165            a: array![[0.0_f64, 0.0], [3.0, 4.0]],
166            b: array![-2.0_f64, 10.0],
167        };
168        let canon = c.canonicalized().unwrap();
169        assert_eq!(canon.a.nrows(), 2);
170        assert_eq!(canon.a[[0, 0]], 0.0);
171        assert_eq!(canon.b[0], -2.0);
172        assert!((canon.a[[1, 0]] - 0.6).abs() < 1e-15);
173        assert!((canon.a[[1, 1]] - 0.8).abs() < 1e-15);
174        assert!((canon.b[1] - 2.0).abs() < 1e-15);
175    }
176
177    #[test]
178    fn canonicalized_rejects_nan_rows() {
179        let c = LinearInequalityConstraints {
180            a: array![[f64::NAN, 0.0]],
181            b: array![0.0_f64],
182        };
183        assert!(c.canonicalized().is_err());
184    }
185
186    #[test]
187    fn new_rejects_non_finite_entries() {
188        let a = array![[f64::NAN]];
189        let b = array![0.0_f64];
190        assert!(LinearInequalityConstraints::new(a, b).is_err());
191    }
192
193    #[test]
194    fn from_lower_bounds_multiple_active_rows() {
195        let bounds = array![0.5_f64, f64::NAN, -1.0];
196        let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
197        assert_eq!(c.a.nrows(), 2);
198        // First row: col 0 active with bound 0.5
199        assert_eq!(c.a[[0, 0]], 1.0);
200        assert_eq!(c.b[0], 0.5);
201        // Second row: col 2 active with bound -1.0
202        assert_eq!(c.a[[1, 2]], 1.0);
203        assert_eq!(c.b[1], -1.0);
204    }
205}