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 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 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 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 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 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 assert_eq!(c.a[[0, 0]], 1.0);
234 assert_eq!(c.b[0], 0.5);
235 assert_eq!(c.a[[1, 2]], 1.0);
237 assert_eq!(c.b[1], -1.0);
238 }
239}