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