use ndarray::{Array1, Array2};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LinearInequalityConstraints {
pub a: Array2<f64>,
pub b: Array1<f64>,
}
impl LinearInequalityConstraints {
#[inline]
pub fn new(a: Array2<f64>, b: Array1<f64>) -> Result<Self, String> {
if a.nrows() != b.len() {
return Err(format!(
"LinearInequalityConstraints: row count mismatch (A has {} rows, b has length {})",
a.nrows(),
b.len(),
));
}
if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
return Err(
"LinearInequalityConstraints: A and b must be finite (a NaN row silently \
evades every feasibility comparison downstream)"
.to_string(),
);
}
Ok(Self { a, b })
}
pub fn canonicalized(&self) -> Result<Self, String> {
let m = self.a.nrows();
if self.b.len() != m {
return Err(format!(
"LinearInequalityConstraints: row count mismatch (A has {m} rows, b has length {})",
self.b.len(),
));
}
if self.a.iter().any(|v| !v.is_finite()) || self.b.iter().any(|v| !v.is_finite()) {
return Err("LinearInequalityConstraints: A and b must be finite".to_string());
}
let mut a = self.a.clone();
let mut b = self.b.clone();
for i in 0..m {
let norm = a.row(i).dot(&a.row(i)).sqrt();
if norm > 0.0 {
a.row_mut(i).mapv_inplace(|v| v / norm);
b[i] /= norm;
} else if b[i] > 0.0 {
return Err(format!(
"LinearInequalityConstraints: row {i} is zero with positive bound \
b = {:.3e}; the constraint 0ᵀβ ≥ b is infeasible",
b[i],
));
}
}
Ok(Self { a, b })
}
pub fn from_per_coordinate_lower_bounds(lower_bounds: &Array1<f64>) -> Option<Self> {
let active_rows: Vec<usize> = (0..lower_bounds.len())
.filter(|&i| lower_bounds[i].is_finite())
.collect();
if active_rows.is_empty() {
return None;
}
let p = lower_bounds.len();
let mut a = Array2::<f64>::zeros((active_rows.len(), p));
let mut b = Array1::<f64>::zeros(active_rows.len());
for (r, &idx) in active_rows.iter().enumerate() {
a[[r, idx]] = 1.0;
b[r] = lower_bounds[idx];
}
Some(Self { a, b })
}
pub fn max_contract_feasible_step(
&self,
beta: ndarray::ArrayView1<'_, f64>,
direction: ndarray::ArrayView1<'_, f64>,
) -> Result<crate::ContractFeasibleStep, crate::ContractFeasibleStepError> {
if beta.len() != self.a.ncols() || direction.len() != self.a.ncols() {
return Err(crate::ContractFeasibleStepError::Dimension {
beta: beta.len(),
direction: direction.len(),
expected: self.a.ncols(),
});
}
let values = self.a.dot(&beta);
let directional = self.a.dot(&direction);
crate::constraint_set::contract_feasible_step_over_rows(
&values,
&directional,
|row| Ok(self.b[row]),
|row| {
let r = self.a.row(row);
Ok(r.dot(&r).sqrt())
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2, array};
#[test]
fn new_ok_when_rows_match_b_len() {
let a = Array2::<f64>::eye(3);
let b = Array1::<f64>::zeros(3);
assert!(LinearInequalityConstraints::new(a, b).is_ok());
}
#[test]
fn new_err_on_row_count_mismatch() {
let a = Array2::<f64>::eye(3);
let b = Array1::<f64>::zeros(2);
assert!(LinearInequalityConstraints::new(a, b).is_err());
}
#[test]
fn from_lower_bounds_none_when_all_non_finite() {
let bounds = array![f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
assert!(LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).is_none());
}
#[test]
fn from_lower_bounds_selects_finite_entries() {
let bounds = array![f64::NAN, 1.0_f64, f64::NAN];
let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
assert_eq!(c.a.nrows(), 1);
assert_eq!(c.a.ncols(), 3);
assert_eq!(c.a[[0, 1]], 1.0);
assert_eq!(c.b[0], 1.0);
}
#[test]
fn canonicalized_is_invariant_to_row_rescaling() {
let tiny = LinearInequalityConstraints {
a: array![[1e-20_f64]],
b: array![1e-20_f64],
}
.canonicalized()
.unwrap();
assert!((tiny.a[[0, 0]] - 1.0).abs() < 1e-15);
assert!((tiny.b[0] - 1.0).abs() < 1e-15);
}
#[test]
fn canonicalized_rejects_infeasible_zero_row() {
let c = LinearInequalityConstraints {
a: array![[0.0_f64, 0.0]],
b: array![1.0_f64],
};
assert!(c.canonicalized().is_err());
}
#[test]
fn canonicalized_keeps_row_indices_and_normalizes_nonzero_rows() {
let c = LinearInequalityConstraints {
a: array![[0.0_f64, 0.0], [3.0, 4.0]],
b: array![-2.0_f64, 10.0],
};
let canon = c.canonicalized().unwrap();
assert_eq!(canon.a.nrows(), 2);
assert_eq!(canon.a[[0, 0]], 0.0);
assert_eq!(canon.b[0], -2.0);
assert!((canon.a[[1, 0]] - 0.6).abs() < 1e-15);
assert!((canon.a[[1, 1]] - 0.8).abs() < 1e-15);
assert!((canon.b[1] - 2.0).abs() < 1e-15);
}
#[test]
fn canonicalized_rejects_nan_rows() {
let c = LinearInequalityConstraints {
a: array![[f64::NAN, 0.0]],
b: array![0.0_f64],
};
assert!(c.canonicalized().is_err());
}
#[test]
fn new_rejects_non_finite_entries() {
let a = array![[f64::NAN]];
let b = array![0.0_f64];
assert!(LinearInequalityConstraints::new(a, b).is_err());
}
#[test]
fn from_lower_bounds_multiple_active_rows() {
let bounds = array![0.5_f64, f64::NAN, -1.0];
let c = LinearInequalityConstraints::from_per_coordinate_lower_bounds(&bounds).unwrap();
assert_eq!(c.a.nrows(), 2);
assert_eq!(c.a[[0, 0]], 1.0);
assert_eq!(c.b[0], 0.5);
assert_eq!(c.a[[1, 2]], 1.0);
assert_eq!(c.b[1], -1.0);
}
}