gam_problem/
linear_constraints.rs1use 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 #[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 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 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 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 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 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 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 assert_eq!(c.a[[0, 0]], 1.0);
199 assert_eq!(c.b[0], 0.5);
200 assert_eq!(c.a[[1, 2]], 1.0);
202 assert_eq!(c.b[1], -1.0);
203 }
204}