use crate::error::GeomError;
use crate::linalg::matrix::Matrix;
const PIVOT_TOL: f64 = 1e-9;
const OPT_TOL: f64 = 1e-9;
const MAX_PIVOTS: usize = 200_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cmp {
Le,
Ge,
Eq,
}
impl Cmp {
fn flipped(self) -> Self {
match self {
Cmp::Le => Cmp::Ge,
Cmp::Ge => Cmp::Le,
Cmp::Eq => Cmp::Eq,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LpProblem {
pub c: Vec<f64>,
pub a: Matrix,
pub b: Vec<f64>,
pub constraint_types: Vec<Cmp>,
pub bounds: Vec<(f64, f64)>,
pub maximize: bool,
}
impl LpProblem {
pub fn new(c: Vec<f64>, a: Matrix, b: Vec<f64>, maximize: bool) -> Result<Self, GeomError> {
let m = b.len();
let p = Self {
constraint_types: vec![Cmp::Le; m],
bounds: vec![(0.0, f64::INFINITY); c.len()],
c,
a,
b,
maximize,
};
p.validate()?;
Ok(p)
}
#[must_use]
pub fn n(&self) -> usize {
self.c.len()
}
#[must_use]
pub fn m(&self) -> usize {
self.b.len()
}
pub fn validate(&self) -> Result<(), GeomError> {
if self.c.is_empty() {
return Err(GeomError::InvalidArgument("an LP needs at least one variable"));
}
if self.a.rows != self.b.len() || self.a.cols != self.c.len() {
return Err(GeomError::InvalidArgument("LP matrix shape does not match c and b"));
}
if self.constraint_types.len() != self.b.len() {
return Err(GeomError::InvalidArgument("one constraint sense per row is required"));
}
if self.bounds.len() != self.c.len() {
return Err(GeomError::InvalidArgument("one bound pair per variable is required"));
}
for (lo, hi) in &self.bounds {
if lo > hi {
return Err(GeomError::InvalidArgument("a lower bound exceeds its upper bound"));
}
if hi.is_infinite() && hi.is_sign_negative() {
return Err(GeomError::InvalidArgument("an upper bound is negative infinity"));
}
}
if self.c.iter().chain(&self.b).any(|v| !v.is_finite()) {
return Err(GeomError::InvalidArgument("LP coefficients must be finite"));
}
Ok(())
}
#[must_use]
pub fn objective_at(&self, x: &[f64]) -> f64 {
self.c.iter().zip(x).map(|(a, b)| a * b).sum()
}
#[must_use]
pub fn is_feasible(&self, x: &[f64], tol: f64) -> bool {
if x.len() != self.n() {
return false;
}
for (j, &v) in x.iter().enumerate() {
let (lo, hi) = self.bounds[j];
if v < lo - tol || v > hi + tol {
return false;
}
}
for i in 0..self.m() {
let row: f64 = (0..self.n()).map(|j| self.a.get(i, j) * x[j]).sum();
let ok = match self.constraint_types[i] {
Cmp::Le => row <= self.b[i] + tol,
Cmp::Ge => row >= self.b[i] - tol,
Cmp::Eq => (row - self.b[i]).abs() <= tol,
};
if !ok {
return false;
}
}
true
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LpResult {
Optimal {
x: Vec<f64>,
objective: f64,
duals: Vec<f64>,
reduced_costs: Vec<f64>,
},
Infeasible,
Unbounded,
}
impl LpResult {
#[must_use]
pub fn objective(&self) -> Option<f64> {
match self {
LpResult::Optimal { objective, .. } => Some(*objective),
_ => None,
}
}
#[must_use]
pub fn solution(&self) -> Option<&[f64]> {
match self {
LpResult::Optimal { x, .. } => Some(x),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
enum VarMap {
Shifted { index: usize, shift: f64 },
Split { plus: usize, minus: usize },
}
struct Standard {
a: Matrix,
b: Vec<f64>,
c: Vec<f64>,
maps: Vec<VarMap>,
row_of: Vec<(usize, bool)>,
structural: usize,
maximize: bool,
}
fn standardize(p: &LpProblem) -> Result<Standard, GeomError> {
p.validate()?;
let n = p.n();
let mut maps = Vec::with_capacity(n);
let mut structural = 0usize;
for &(lo, _) in &p.bounds {
if lo.is_infinite() {
maps.push(VarMap::Split { plus: structural, minus: structural + 1 });
structural += 2;
} else {
maps.push(VarMap::Shifted { index: structural, shift: lo });
structural += 1;
}
}
let sign = if p.maximize { -1.0 } else { 1.0 };
let mut c = vec![0.0; structural];
for (j, &cj) in p.c.iter().enumerate() {
match maps[j] {
VarMap::Shifted { index, shift } => {
c[index] = sign * cj;
let _ = shift;
}
VarMap::Split { plus, minus } => {
c[plus] = sign * cj;
c[minus] = -sign * cj;
}
}
}
let mut rows: Vec<(Vec<f64>, f64, Cmp)> = Vec::new();
let mut row_of = Vec::with_capacity(p.m());
for i in 0..p.m() {
let mut coeffs = vec![0.0; structural];
let mut rhs = p.b[i];
for j in 0..n {
let aij = p.a.get(i, j);
if aij == 0.0 {
continue;
}
match maps[j] {
VarMap::Shifted { index, shift } => {
coeffs[index] += aij;
rhs -= aij * shift;
}
VarMap::Split { plus, minus } => {
coeffs[plus] += aij;
coeffs[minus] -= aij;
}
}
}
row_of.push((rows.len(), false));
rows.push((coeffs, rhs, p.constraint_types[i]));
}
for (j, &(lo, hi)) in p.bounds.iter().enumerate() {
if hi.is_finite() {
let mut coeffs = vec![0.0; structural];
match maps[j] {
VarMap::Shifted { index, shift } => {
coeffs[index] = 1.0;
rows.push((coeffs, hi - shift, Cmp::Le));
}
VarMap::Split { plus, minus } => {
coeffs[plus] = 1.0;
coeffs[minus] = -1.0;
rows.push((coeffs, hi, Cmp::Le));
}
}
debug_assert!(lo.is_infinite() || hi >= lo);
}
}
for (i, row) in rows.iter_mut().enumerate() {
if row.1 < 0.0 {
for v in &mut row.0 {
*v = -*v;
}
row.1 = -row.1;
row.2 = row.2.flipped();
if let Some(entry) = row_of.iter_mut().find(|e| e.0 == i) {
entry.1 = true;
}
}
}
let extra = rows.iter().filter(|r| r.2 != Cmp::Eq).count();
let total = structural + extra;
let m = rows.len();
let mut a = Matrix::zeros(m, total);
let mut b = vec![0.0; m];
let mut next_slack = structural;
for (i, (coeffs, rhs, cmp)) in rows.iter().enumerate() {
for (j, &v) in coeffs.iter().enumerate() {
a.set(i, j, v);
}
b[i] = *rhs;
match cmp {
Cmp::Le => {
a.set(i, next_slack, 1.0);
next_slack += 1;
}
Cmp::Ge => {
a.set(i, next_slack, -1.0);
next_slack += 1;
}
Cmp::Eq => {}
}
}
c.resize(total, 0.0);
Ok(Standard { a, b, c, maps, row_of, structural, maximize: p.maximize })
}
struct Tableau {
t: Vec<Vec<f64>>,
z: Vec<f64>,
basis: Vec<usize>,
m: usize,
n: usize,
}
impl Tableau {
fn pivot(&mut self, row: usize, col: usize) {
let p = self.t[row][col];
debug_assert!(p.abs() > PIVOT_TOL);
for v in &mut self.t[row] {
*v /= p;
}
for r in 0..self.m {
if r == row {
continue;
}
let factor = self.t[r][col];
if factor == 0.0 {
continue;
}
for k in 0..=self.n {
self.t[r][k] -= factor * self.t[row][k];
}
}
let factor = self.z[col];
if factor != 0.0 {
for k in 0..=self.n {
self.z[k] -= factor * self.t[row][k];
}
}
self.basis[row] = col;
}
fn solve(&mut self, allowed: &dyn Fn(usize) -> bool) -> bool {
for _ in 0..MAX_PIVOTS {
let mut entering = None;
for j in 0..self.n {
if allowed(j) && self.z[j] < -OPT_TOL {
entering = Some(j);
break;
}
}
let Some(col) = entering else { return true };
let mut best: Option<(f64, usize, usize)> = None;
for r in 0..self.m {
let a = self.t[r][col];
if a <= PIVOT_TOL {
continue;
}
let ratio = self.t[r][self.n] / a;
let candidate = (ratio, self.basis[r], r);
best = match best {
None => Some(candidate),
Some(current) => {
if ratio < current.0 - PIVOT_TOL
|| (ratio < current.0 + PIVOT_TOL && self.basis[r] < current.1)
{
Some(candidate)
} else {
Some(current)
}
}
};
}
let Some((_, _, row)) = best else {
return false;
};
self.pivot(row, col);
}
true
}
}
pub fn simplex(p: &LpProblem) -> Result<LpResult, GeomError> {
Ok(solve_tableau(p)?.1)
}
fn solve_tableau(p: &LpProblem) -> Result<(Option<(Tableau, Standard)>, LpResult), GeomError> {
let s = standardize(p)?;
let m = s.b.len();
let n = s.c.len();
if m == 0 {
if s.c.iter().any(|&v| v < -OPT_TOL) {
return Ok((None, LpResult::Unbounded));
}
let x = vec![0.0; p.n()];
let objective = p.objective_at(&x);
return Ok((
None,
LpResult::Optimal { x, objective, duals: Vec::new(), reduced_costs: p.c.clone() },
));
}
let width = n + m;
let mut t = vec![vec![0.0; width + 1]; m];
for i in 0..m {
for j in 0..n {
t[i][j] = s.a.get(i, j);
}
t[i][n + i] = 1.0;
t[i][width] = s.b[i];
}
let mut z = vec![0.0; width + 1];
for (j, entry) in z.iter_mut().enumerate().take(n) {
*entry = -(0..m).map(|i| t[i][j]).sum::<f64>();
}
z[width] = -(0..m).map(|i| t[i][width]).sum::<f64>();
let mut tab = Tableau { t, z, basis: (n..n + m).collect(), m, n: width };
let real = |j: usize| j < n;
let all = |_: usize| true;
tab.solve(&all);
if -tab.z[width] > 1e-7 {
return Ok((None, LpResult::Infeasible));
}
for r in 0..m {
if tab.basis[r] >= n {
let replacement = (0..n).find(|&j| tab.t[r][j].abs() > PIVOT_TOL);
if let Some(col) = replacement {
tab.pivot(r, col);
}
}
}
let mut z = vec![0.0; width + 1];
z[..n].copy_from_slice(&s.c[..n]);
for r in 0..m {
let col = tab.basis[r];
if col < n && s.c[col] != 0.0 {
let factor = z[col];
if factor != 0.0 {
for k in 0..=width {
z[k] -= factor * tab.t[r][k];
}
}
}
}
tab.z = z;
if !tab.solve(&real) {
return Ok((None, LpResult::Unbounded));
}
let result = extract(&tab, &s, p, n);
Ok((Some((tab, s)), result))
}
fn extract(tab: &Tableau, s: &Standard, p: &LpProblem, n: usize) -> LpResult {
let m = s.b.len();
let mut y = vec![0.0; n];
for r in 0..m {
if tab.basis[r] < n {
y[tab.basis[r]] = tab.t[r][tab.n];
}
}
let mut x = vec![0.0; p.n()];
for (j, map) in s.maps.iter().enumerate() {
x[j] = match *map {
VarMap::Shifted { index, shift } => shift + y[index],
VarMap::Split { plus, minus } => y[plus] - y[minus],
};
}
let objective = p.objective_at(&x);
let mut slack_of = vec![None; m];
let mut next = s.structural;
for (i, ct) in row_senses(s).iter().enumerate() {
if *ct != Cmp::Eq {
slack_of[i] = Some(next);
next += 1;
}
}
let sign = if s.maximize { -1.0 } else { 1.0 };
let mut duals = vec![0.0; p.m()];
for (i, &(row, negated)) in s.row_of.iter().enumerate() {
let raw = match slack_of[row] {
Some(col) => {
let sense = row_senses(s)[row];
match sense {
Cmp::Le => -tab.z[col],
Cmp::Ge => tab.z[col],
Cmp::Eq => 0.0,
}
}
None => -tab.z[s.structural + slack_count(s) + row],
};
let oriented = if negated { -raw } else { raw };
duals[i] = sign * oriented;
}
let reduced_costs = (0..p.n())
.map(|j| {
p.c[j] - (0..p.m()).map(|i| duals[i] * p.a.get(i, j)).sum::<f64>()
})
.collect();
LpResult::Optimal { x, objective, duals, reduced_costs }
}
fn row_senses(s: &Standard) -> Vec<Cmp> {
let m = s.b.len();
let mut out = vec![Cmp::Eq; m];
let mut next = s.structural;
for (i, entry) in out.iter_mut().enumerate() {
if next < s.a.cols {
let v = s.a.get(i, next);
if v == 1.0 {
*entry = Cmp::Le;
next += 1;
continue;
} else if v == -1.0 {
*entry = Cmp::Ge;
next += 1;
continue;
}
}
*entry = Cmp::Eq;
}
out
}
fn slack_count(s: &Standard) -> usize {
s.a.cols - s.structural
}
pub fn lp_dual(p: &LpProblem) -> Result<LpProblem, GeomError> {
p.validate()?;
if p.bounds.iter().any(|&(lo, hi)| lo != 0.0 || hi.is_finite()) {
return Err(GeomError::InvalidArgument(
"lp_dual requires the default non-negative variable bounds",
));
}
let (m, n) = (p.m(), p.n());
let mut a = Matrix::zeros(n, m);
for i in 0..m {
for j in 0..n {
a.set(j, i, p.a.get(i, j));
}
}
let (sense, bounds): (Cmp, Vec<(f64, f64)>) = if p.maximize {
(
Cmp::Ge,
p.constraint_types
.iter()
.map(|c| match c {
Cmp::Le => (0.0, f64::INFINITY),
Cmp::Ge => (f64::NEG_INFINITY, 0.0),
Cmp::Eq => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect(),
)
} else {
(
Cmp::Le,
p.constraint_types
.iter()
.map(|c| match c {
Cmp::Le => (f64::NEG_INFINITY, 0.0),
Cmp::Ge => (0.0, f64::INFINITY),
Cmp::Eq => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect(),
)
};
Ok(LpProblem {
c: p.b.clone(),
a,
b: p.c.clone(),
constraint_types: vec![sense; n],
bounds,
maximize: !p.maximize,
})
}
pub fn sensitivity_ranges(
p: &LpProblem,
) -> Result<(Vec<(f64, f64)>, Vec<(f64, f64)>), GeomError> {
if p.bounds.iter().any(|&(lo, hi)| lo != 0.0 || hi.is_finite()) {
return Err(GeomError::InvalidArgument(
"sensitivity_ranges requires the default non-negative variable bounds",
));
}
let (solved, result) = solve_tableau(p)?;
let LpResult::Optimal { .. } = result else {
return Err(GeomError::Degenerate("sensitivity_ranges requires an optimal solution"));
};
let Some((tab, s)) = solved else {
return Err(GeomError::Degenerate("sensitivity_ranges requires a constrained problem"));
};
let m = s.b.len();
let structural = s.structural;
let senses = row_senses(&s);
let mut slack_of = vec![None; m];
let mut next = structural;
for (i, sense) in senses.iter().enumerate() {
if *sense != Cmp::Eq {
slack_of[i] = Some(next);
next += 1;
}
}
let mut b_ranges = Vec::with_capacity(p.m());
for (i, &(row, negated)) in s.row_of.iter().enumerate() {
let Some(col) = slack_of[row] else {
b_ranges.push((p.b[i], p.b[i]));
continue;
};
let orientation = match senses[row] {
Cmp::Le => 1.0,
Cmp::Ge => -1.0,
Cmp::Eq => 0.0,
} * if negated { -1.0 } else { 1.0 };
let (mut down, mut up) = (f64::NEG_INFINITY, f64::INFINITY);
for r in 0..m {
let direction = orientation * tab.t[r][col];
if direction.abs() < PIVOT_TOL {
continue;
}
let limit = -tab.t[r][tab.n] / direction;
if direction > 0.0 {
down = down.max(limit);
} else {
up = up.min(limit);
}
}
b_ranges.push((p.b[i] + down, p.b[i] + up));
}
let sign = if s.maximize { -1.0 } else { 1.0 };
let mut c_ranges = Vec::with_capacity(p.n());
for j in 0..p.n() {
let VarMap::Shifted { index, .. } = s.maps[j] else {
c_ranges.push((f64::NEG_INFINITY, f64::INFINITY));
continue;
};
let basic_row = (0..m).find(|&r| tab.basis[r] == index);
let (down, up) = match basic_row {
None => {
(-tab.z[index], f64::INFINITY)
}
Some(r) => {
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
for k in 0..tab.n {
let a = tab.t[r][k];
if a.abs() < PIVOT_TOL || tab.basis.contains(&k) {
continue;
}
let ratio = tab.z[k] / a;
if a > 0.0 {
hi = hi.min(ratio);
} else {
lo = lo.max(ratio);
}
}
(lo, hi)
}
};
let (a, b) = (p.c[j] + sign * down, p.c[j] + sign * up);
c_ranges.push((a.min(b), a.max(b)));
}
Ok((c_ranges, b_ranges))
}
pub fn dual_simplex(p: &LpProblem, basis: &[usize]) -> Result<LpResult, GeomError> {
let s = standardize(p)?;
let m = s.b.len();
let n = s.c.len();
if basis.len() != m {
return Err(GeomError::InvalidArgument("dual_simplex needs one basic column per row"));
}
if basis.iter().any(|&j| j >= n) {
return Err(GeomError::InvalidArgument("dual_simplex basis names a column out of range"));
}
let mut t = vec![vec![0.0; n + 1]; m];
for i in 0..m {
for j in 0..n {
t[i][j] = s.a.get(i, j);
}
t[i][n] = s.b[i];
}
let mut tab = Tableau { t, z: vec![0.0; n + 1], basis: vec![usize::MAX; m], m, n };
for (r, &col) in basis.iter().enumerate() {
if tab.t[r][col].abs() < PIVOT_TOL {
let swap = (r + 1..m).find(|&k| tab.t[k][col].abs() > PIVOT_TOL);
let Some(k) = swap else {
return Err(GeomError::Degenerate("dual_simplex basis is singular"));
};
tab.t.swap(r, k);
}
tab.pivot(r, col);
}
let mut z = s.c.clone();
z.push(0.0);
for r in 0..m {
let factor = z[tab.basis[r]];
if factor != 0.0 {
for k in 0..=n {
z[k] -= factor * tab.t[r][k];
}
}
}
tab.z = z;
if tab.z[..n].iter().any(|&v| v < -OPT_TOL) {
return Err(GeomError::Degenerate("dual_simplex requires a dual-feasible basis"));
}
for _ in 0..MAX_PIVOTS {
let mut leaving: Option<usize> = None;
for r in 0..m {
if tab.t[r][n] < -PIVOT_TOL
&& leaving.is_none_or(|best| tab.t[r][n] < tab.t[best][n])
{
leaving = Some(r);
}
}
let Some(row) = leaving else {
let result = extract(&tab, &s, p, n);
return Ok(result);
};
let mut entering: Option<(f64, usize)> = None;
for j in 0..n {
let a = tab.t[row][j];
if a >= -PIVOT_TOL {
continue;
}
let ratio = tab.z[j] / -a;
if entering.is_none_or(|(best, _)| ratio < best) {
entering = Some((ratio, j));
}
}
let Some((_, col)) = entering else {
return Ok(LpResult::Infeasible);
};
tab.pivot(row, col);
}
Err(GeomError::Degenerate("dual_simplex did not terminate"))
}
const IP_MAX_ITER: usize = 200;
const IP_STEP_FRACTION: f64 = 0.995;
const IP_SIGMA: f64 = 0.2;
pub fn interior_point(p: &LpProblem, tol: f64) -> Result<LpResult, GeomError> {
if !(tol > 0.0) {
return Err(GeomError::InvalidArgument("interior_point requires tol > 0"));
}
let s = standardize(p)?;
let m = s.b.len();
let n = s.c.len();
if m == 0 {
return simplex(p);
}
let mut x = vec![1.0; n];
let mut slack = vec![1.0; n];
let mut y = vec![0.0; m];
let mut converged = false;
for _ in 0..IP_MAX_ITER {
let ax: Vec<f64> = (0..m)
.map(|i| (0..n).map(|j| s.a.get(i, j) * x[j]).sum::<f64>())
.collect();
let r_p: Vec<f64> = (0..m).map(|i| s.b[i] - ax[i]).collect();
let r_d: Vec<f64> = (0..n)
.map(|j| {
s.c[j] - (0..m).map(|i| s.a.get(i, j) * y[i]).sum::<f64>() - slack[j]
})
.collect();
let mu: f64 = x.iter().zip(&slack).map(|(a, b)| a * b).sum::<f64>() / n as f64;
let primal_err = r_p.iter().map(|v| v.abs()).fold(0.0f64, f64::max);
let dual_err = r_d.iter().map(|v| v.abs()).fold(0.0f64, f64::max);
if primal_err < tol && dual_err < tol && mu < tol {
converged = true;
break;
}
let d: Vec<f64> = (0..n).map(|j| x[j] / slack[j].max(1e-300)).collect();
let mut normal = Matrix::zeros(m, m);
for i in 0..m {
for k in i..m {
let v: f64 =
(0..n).map(|j| s.a.get(i, j) * d[j] * s.a.get(k, j)).sum();
normal.set(i, k, v);
normal.set(k, i, v);
}
let diagonal = normal.get(i, i);
normal.set(i, i, diagonal + 1e-12 * (1.0 + diagonal));
}
let rhs: Vec<f64> = (0..m)
.map(|i| {
s.b[i]
- IP_SIGMA
* mu
* (0..n).map(|j| s.a.get(i, j) / slack[j].max(1e-300)).sum::<f64>()
+ (0..n).map(|j| s.a.get(i, j) * d[j] * r_d[j]).sum::<f64>()
})
.collect();
let Ok(factor) = crate::linalg::cholesky::cholesky(&normal) else {
break;
};
let Ok(dy) = crate::linalg::cholesky::cholesky_solve(&factor, &rhs) else {
break;
};
let ds: Vec<f64> = (0..n)
.map(|j| r_d[j] - (0..m).map(|i| s.a.get(i, j) * dy[i]).sum::<f64>())
.collect();
let dx: Vec<f64> = (0..n)
.map(|j| IP_SIGMA * mu / slack[j].max(1e-300) - x[j] - d[j] * ds[j])
.collect();
if dx.iter().chain(&ds).chain(&dy).any(|v| !v.is_finite()) {
break;
}
let step = |v: &[f64], dv: &[f64]| -> f64 {
let mut alpha = 1.0f64;
for (a, b) in v.iter().zip(dv) {
if *b < 0.0 {
alpha = alpha.min(-a / b);
}
}
(IP_STEP_FRACTION * alpha).min(1.0)
};
let alpha_p = step(&x, &dx);
let alpha_d = step(&slack, &ds);
for j in 0..n {
x[j] = (x[j] + alpha_p * dx[j]).max(1e-300);
slack[j] = (slack[j] + alpha_d * ds[j]).max(1e-300);
}
for i in 0..m {
y[i] += alpha_d * dy[i];
}
}
if !converged {
return simplex(p);
}
let mut out = vec![0.0; p.n()];
for (j, map) in s.maps.iter().enumerate() {
out[j] = match *map {
VarMap::Shifted { index, shift } => shift + x[index],
VarMap::Split { plus, minus } => x[plus] - x[minus],
};
}
let objective = p.objective_at(&out);
let sign = if s.maximize { -1.0 } else { 1.0 };
let mut duals = vec![0.0; p.m()];
for (i, &(row, negated)) in s.row_of.iter().enumerate() {
let raw = y[row];
duals[i] = sign * if negated { -raw } else { raw };
}
let reduced_costs = (0..p.n())
.map(|j| p.c[j] - (0..p.m()).map(|i| duals[i] * p.a.get(i, j)).sum::<f64>())
.collect();
Ok(LpResult::Optimal { x: out, objective, duals, reduced_costs })
}
pub fn lp_from_str(text: &str) -> Result<LpProblem, GeomError> {
#[derive(PartialEq)]
enum Section {
Objective,
Constraints,
Bounds,
}
let mut names: Vec<String> = Vec::new();
let mut maximize = false;
let mut objective: Vec<(usize, f64)> = Vec::new();
let mut rows: Vec<(Vec<(usize, f64)>, Cmp, f64)> = Vec::new();
let mut bound_lines: Vec<(usize, Cmp, f64)> = Vec::new();
let mut free: Vec<usize> = Vec::new();
let mut section = Section::Objective;
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let lower = line.to_ascii_lowercase();
if lower == "subject to" || lower == "st" || lower == "s.t." || lower == "such that" {
section = Section::Constraints;
continue;
}
if lower == "bounds" {
section = Section::Bounds;
continue;
}
match section {
Section::Objective => {
let rest = if let Some(r) = lower.strip_prefix("max") {
maximize = true;
&line[line.len() - r.len()..]
} else if let Some(r) = lower.strip_prefix("min") {
&line[line.len() - r.len()..]
} else {
return Err(GeomError::InvalidArgument(
"the first line must start with max or min",
));
};
objective = parse_terms(rest, &mut names)?;
section = Section::Constraints;
}
Section::Constraints => {
let (terms, cmp, rhs) = parse_row(line, &mut names)?;
rows.push((terms, cmp, rhs));
}
Section::Bounds => {
if let Some(rest) = lower.strip_prefix("free ") {
let name = rest.trim().to_string();
let idx = index_of(&name, &mut names);
free.push(idx);
continue;
}
let (terms, cmp, rhs) = parse_row(line, &mut names)?;
if terms.len() != 1 || (terms[0].1 - 1.0).abs() > 1e-12 {
return Err(GeomError::InvalidArgument(
"a bounds line must name a single variable with coefficient one",
));
}
bound_lines.push((terms[0].0, cmp, rhs));
}
}
}
let n = names.len();
if n == 0 {
return Err(GeomError::InvalidArgument("the model names no variables"));
}
let mut c = vec![0.0; n];
for (j, v) in objective {
c[j] += v;
}
let m = rows.len();
let mut a = Matrix::zeros(m, n);
let mut b = vec![0.0; m];
let mut constraint_types = Vec::with_capacity(m);
for (i, (terms, cmp, rhs)) in rows.into_iter().enumerate() {
for (j, v) in terms {
a.set(i, j, a.get(i, j) + v);
}
b[i] = rhs;
constraint_types.push(cmp);
}
let mut bounds = vec![(0.0, f64::INFINITY); n];
for j in free {
bounds[j].0 = f64::NEG_INFINITY;
}
for (j, cmp, value) in bound_lines {
match cmp {
Cmp::Ge => bounds[j].0 = value,
Cmp::Le => bounds[j].1 = value,
Cmp::Eq => bounds[j] = (value, value),
}
}
let p = LpProblem { c, a, b, constraint_types, bounds, maximize };
p.validate()?;
Ok(p)
}
fn index_of(name: &str, names: &mut Vec<String>) -> usize {
if let Some(i) = names.iter().position(|n| n == name) {
return i;
}
names.push(name.to_string());
names.len() - 1
}
fn parse_terms(text: &str, names: &mut Vec<String>) -> Result<Vec<(usize, f64)>, GeomError> {
let mut out = Vec::new();
let spaced = text.replace('+', " + ").replace('-', " - ");
let tokens: Vec<&str> = spaced.split_whitespace().collect();
let mut sign = 1.0f64;
let mut i = 0usize;
while i < tokens.len() {
match tokens[i] {
"+" => {
sign = 1.0;
i += 1;
}
"-" => {
sign = -1.0;
i += 1;
}
token => {
let body = token.trim_start_matches('*');
let split = body.find(|ch: char| ch.is_alphabetic() || ch == '_');
let (coefficient, name) = match split {
Some(0) => (1.0, body),
Some(k) => {
let head = body[..k].trim_end_matches('*');
let value: f64 = head
.parse()
.map_err(|_| GeomError::InvalidArgument("bad coefficient"))?;
(value, &body[k..])
}
None => {
return Err(GeomError::InvalidArgument(
"a term with no variable appeared on the left-hand side",
))
}
};
out.push((index_of(name, names), sign * coefficient));
sign = 1.0;
i += 1;
}
}
}
Ok(out)
}
fn parse_row(
line: &str,
names: &mut Vec<String>,
) -> Result<(Vec<(usize, f64)>, Cmp, f64), GeomError> {
for (token, cmp) in [("<=", Cmp::Le), (">=", Cmp::Ge), ("=<", Cmp::Le), ("=>", Cmp::Ge)] {
if let Some(k) = line.find(token) {
let rhs: f64 = line[k + token.len()..]
.trim()
.parse()
.map_err(|_| GeomError::InvalidArgument("bad right-hand side"))?;
return Ok((parse_terms(&line[..k], names)?, cmp, rhs));
}
}
if let Some(k) = line.find('=') {
let rhs: f64 = line[k + 1..]
.trim()
.parse()
.map_err(|_| GeomError::InvalidArgument("bad right-hand side"))?;
return Ok((parse_terms(&line[..k], names)?, Cmp::Eq, rhs));
}
Err(GeomError::InvalidArgument("a constraint line needs a comparison operator"))
}
pub fn diet_problem(
costs: &[f64],
nutrients: &Matrix,
requirements: &[f64],
) -> Result<LpProblem, GeomError> {
if nutrients.cols != costs.len() || nutrients.rows != requirements.len() {
return Err(GeomError::InvalidArgument("diet_problem: shape mismatch"));
}
let p = LpProblem {
c: costs.to_vec(),
a: nutrients.clone(),
b: requirements.to_vec(),
constraint_types: vec![Cmp::Ge; requirements.len()],
bounds: vec![(0.0, f64::INFINITY); costs.len()],
maximize: false,
};
p.validate()?;
Ok(p)
}
pub fn production_planning(
profits: &[f64],
usage: &Matrix,
available: &[f64],
) -> Result<LpProblem, GeomError> {
if usage.cols != profits.len() || usage.rows != available.len() {
return Err(GeomError::InvalidArgument("production_planning: shape mismatch"));
}
let p = LpProblem {
c: profits.to_vec(),
a: usage.clone(),
b: available.to_vec(),
constraint_types: vec![Cmp::Le; available.len()],
bounds: vec![(0.0, f64::INFINITY); profits.len()],
maximize: true,
};
p.validate()?;
Ok(p)
}
pub fn transportation_problem(
supply: &[f64],
demand: &[f64],
costs: &Matrix,
) -> Result<LpResult, GeomError> {
let (m, n) = (supply.len(), demand.len());
if costs.rows != m || costs.cols != n || m == 0 || n == 0 {
return Err(GeomError::InvalidArgument("transportation_problem: shape mismatch"));
}
if supply.iter().chain(demand).any(|&v| v < 0.0) {
return Err(GeomError::InvalidArgument("supply and demand must be non-negative"));
}
if demand.iter().sum::<f64>() > supply.iter().sum::<f64>() + OPT_TOL {
return Ok(LpResult::Infeasible);
}
let vars = m * n;
let mut a = Matrix::zeros(m + n, vars);
let mut b = vec![0.0; m + n];
let mut senses = Vec::with_capacity(m + n);
for i in 0..m {
for j in 0..n {
a.set(i, i * n + j, 1.0);
}
b[i] = supply[i];
senses.push(Cmp::Le);
}
for j in 0..n {
for i in 0..m {
a.set(m + j, i * n + j, 1.0);
}
b[m + j] = demand[j];
senses.push(Cmp::Ge);
}
let c: Vec<f64> = (0..m).flat_map(|i| (0..n).map(move |j| (i, j))).map(|(i, j)| costs.get(i, j)).collect();
let p = LpProblem {
c,
a,
b,
constraint_types: senses,
bounds: vec![(0.0, f64::INFINITY); vars],
maximize: false,
};
simplex(&p)
}
pub fn two_player_zero_sum_lp(payoff: &Matrix) -> Result<(Vec<f64>, Vec<f64>, f64), GeomError> {
let (m, n) = (payoff.rows, payoff.cols);
let lowest = (0..m)
.flat_map(|i| (0..n).map(move |j| (i, j)))
.map(|(i, j)| payoff.get(i, j))
.fold(f64::INFINITY, f64::min);
let shift = 1.0 - lowest;
let mut a = Matrix::zeros(n, m);
for j in 0..n {
for i in 0..m {
a.set(j, i, payoff.get(i, j) + shift);
}
}
let p = LpProblem {
c: vec![1.0; m],
a,
b: vec![1.0; n],
constraint_types: vec![Cmp::Ge; n],
bounds: vec![(0.0, f64::INFINITY); m],
maximize: false,
};
let LpResult::Optimal { x, objective, duals, .. } = simplex(&p)? else {
return Err(GeomError::Degenerate("the game program has no optimum"));
};
if objective <= OPT_TOL {
return Err(GeomError::Degenerate("the game program produced a non-positive total"));
}
let value = 1.0 / objective;
let row: Vec<f64> = x.iter().map(|v| v * value).collect();
let column: Vec<f64> = duals.iter().map(|v| v * value).collect();
Ok((row, column, value - shift))
}
pub fn chebyshev_center(a: &Matrix, b: &[f64]) -> Result<(Vec<f64>, f64), GeomError> {
let (m, n) = (a.rows, a.cols);
if b.len() != m {
return Err(GeomError::InvalidArgument("chebyshev_center: shape mismatch"));
}
let mut design = Matrix::zeros(m, n + 1);
for i in 0..m {
let norm: f64 = (0..n).map(|j| a.get(i, j) * a.get(i, j)).sum::<f64>().sqrt();
if norm <= 0.0 {
return Err(GeomError::Degenerate("chebyshev_center: a constraint row is all zeros"));
}
for j in 0..n {
design.set(i, j, a.get(i, j));
}
design.set(i, n, norm);
}
let mut c = vec![0.0; n + 1];
c[n] = 1.0;
let mut bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); n + 1];
bounds[n] = (0.0, f64::INFINITY);
let p = LpProblem {
c,
a: design,
b: b.to_vec(),
constraint_types: vec![Cmp::Le; m],
bounds,
maximize: true,
};
match simplex(&p)? {
LpResult::Optimal { x, .. } => Ok((x[..n].to_vec(), x[n])),
LpResult::Unbounded => Ok((vec![0.0; n], f64::INFINITY)),
LpResult::Infeasible => Err(GeomError::Degenerate("chebyshev_center: the region is empty")),
}
}
pub fn l1_regression_lp(x: &Matrix, y: &[f64]) -> Result<Vec<f64>, GeomError> {
let (n, k) = (x.rows, x.cols);
if y.len() != n || n == 0 || k == 0 {
return Err(GeomError::InvalidArgument("l1_regression_lp: shape mismatch"));
}
let vars = k + 2 * n;
let mut a = Matrix::zeros(n, vars);
for i in 0..n {
for j in 0..k {
a.set(i, j, x.get(i, j));
}
a.set(i, k + i, 1.0);
a.set(i, k + n + i, -1.0);
}
let mut c = vec![0.0; vars];
for entry in c.iter_mut().skip(k) {
*entry = 1.0;
}
let mut bounds = vec![(0.0, f64::INFINITY); vars];
for entry in bounds.iter_mut().take(k) {
*entry = (f64::NEG_INFINITY, f64::INFINITY);
}
let p = LpProblem {
c,
a,
b: y.to_vec(),
constraint_types: vec![Cmp::Eq; n],
bounds,
maximize: false,
};
match simplex(&p)? {
LpResult::Optimal { x: sol, .. } => Ok(sol[..k].to_vec()),
other => Err(match other {
LpResult::Infeasible => GeomError::Degenerate("l1_regression_lp: infeasible"),
_ => GeomError::Degenerate("l1_regression_lp: unbounded"),
}),
}
}
pub fn linf_regression_lp(x: &Matrix, y: &[f64]) -> Result<Vec<f64>, GeomError> {
let (n, k) = (x.rows, x.cols);
if y.len() != n || n == 0 || k == 0 {
return Err(GeomError::InvalidArgument("linf_regression_lp: shape mismatch"));
}
let vars = k + 1;
let mut a = Matrix::zeros(2 * n, vars);
let mut b = vec![0.0; 2 * n];
for i in 0..n {
for j in 0..k {
a.set(i, j, x.get(i, j));
a.set(n + i, j, -x.get(i, j));
}
a.set(i, k, -1.0);
a.set(n + i, k, -1.0);
b[i] = y[i];
b[n + i] = -y[i];
}
let mut c = vec![0.0; vars];
c[k] = 1.0;
let mut bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); vars];
bounds[k] = (0.0, f64::INFINITY);
let p = LpProblem {
c,
a,
b,
constraint_types: vec![Cmp::Le; 2 * n],
bounds,
maximize: false,
};
match simplex(&p)? {
LpResult::Optimal { x: sol, .. } => Ok(sol[..k].to_vec()),
other => Err(match other {
LpResult::Infeasible => GeomError::Degenerate("linf_regression_lp: infeasible"),
_ => GeomError::Degenerate("linf_regression_lp: unbounded"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monte_carlo::Rng;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol * (1.0 + a.abs().max(b.abs()))
}
fn optimum(r: &LpResult) -> (&[f64], f64, &[f64], &[f64]) {
match r {
LpResult::Optimal { x, objective, duals, reduced_costs } => {
(x, *objective, duals, reduced_costs)
}
other => panic!("expected an optimum, got {other:?}"),
}
}
fn textbook() -> LpProblem {
let a = Matrix::from_rows(&[&[1.0, 0.0], &[0.0, 2.0], &[3.0, 2.0]]).unwrap();
LpProblem::new(vec![3.0, 5.0], a, vec![4.0, 12.0, 18.0], true).unwrap()
}
#[test]
fn a_hand_worked_maximisation_matches_its_known_optimum() {
let p = textbook();
let r = simplex(&p).unwrap();
let (x, objective, duals, reduced_costs) = optimum(&r);
assert!((x[0] - 2.0).abs() < 1e-9 && (x[1] - 6.0).abs() < 1e-9, "x = {x:?}");
assert!((objective - 36.0).abs() < 1e-9, "objective {objective}");
assert!(duals[0].abs() < 1e-9, "duals {duals:?}");
assert!((duals[1] - 1.5).abs() < 1e-9, "duals {duals:?}");
assert!((duals[2] - 1.0).abs() < 1e-9, "duals {duals:?}");
assert!(reduced_costs.iter().all(|v| v.abs() < 1e-9), "rc {reduced_costs:?}");
assert!(p.is_feasible(x, 1e-9));
}
#[test]
fn a_minimisation_with_ge_rows_matches_its_known_optimum() {
let a = Matrix::from_rows(&[&[1.0, 1.0], &[1.0, 0.0], &[0.0, 1.0]]).unwrap();
let p = LpProblem {
c: vec![2.0, 3.0],
a,
b: vec![10.0, 3.0, 2.0],
constraint_types: vec![Cmp::Ge; 3],
bounds: vec![(0.0, f64::INFINITY); 2],
maximize: false,
};
let r = simplex(&p).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!((objective - 22.0).abs() < 1e-9, "objective {objective}, x = {x:?}");
assert!(p.is_feasible(x, 1e-9));
}
#[test]
fn equality_rows_free_variables_and_bounds_are_all_honoured() {
let p = LpProblem {
c: vec![1.0, 1.0],
a: Matrix::from_rows(&[&[1.0, 1.0]]).unwrap(),
b: vec![5.0],
constraint_types: vec![Cmp::Eq],
bounds: vec![(0.0, f64::INFINITY), (f64::NEG_INFINITY, f64::INFINITY)],
maximize: false,
};
let r = simplex(&p).unwrap();
let (x, objective, duals, _) = optimum(&r);
assert!((objective - 5.0).abs() < 1e-9);
assert!(p.is_feasible(x, 1e-9), "x = {x:?}");
assert!((duals[0] - 1.0).abs() < 1e-9, "the equality dual is {}", duals[0]);
let q = LpProblem {
c: vec![1.0, 1.0],
a: Matrix::from_rows(&[&[1.0, -1.0]]).unwrap(),
b: vec![4.0],
constraint_types: vec![Cmp::Eq],
bounds: vec![(0.0, f64::INFINITY), (f64::NEG_INFINITY, f64::INFINITY)],
maximize: false,
};
let r = simplex(&q).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!(q.is_feasible(x, 1e-9), "x = {x:?}");
assert!(objective < 0.0 || x[1] < 1e-9, "the free variable stayed pinned: {x:?}");
let bounded = LpProblem {
c: vec![1.0, 1.0],
a: Matrix::from_rows(&[&[1.0, 1.0]]).unwrap(),
b: vec![100.0],
constraint_types: vec![Cmp::Le],
bounds: vec![(1.0, 3.0), (2.0, 4.0)],
maximize: true,
};
let r = simplex(&bounded).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!((x[0] - 3.0).abs() < 1e-9 && (x[1] - 4.0).abs() < 1e-9, "x = {x:?}");
assert!((objective - 7.0).abs() < 1e-9);
let floored = LpProblem {
c: vec![1.0, 1.0],
a: Matrix::from_rows(&[&[1.0, 1.0]]).unwrap(),
b: vec![100.0],
constraint_types: vec![Cmp::Le],
bounds: vec![(5.0, f64::INFINITY), (7.0, f64::INFINITY)],
maximize: false,
};
let r = simplex(&floored).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!((objective - 12.0).abs() < 1e-9, "objective {objective}, x = {x:?}");
}
#[test]
fn infeasible_and_unbounded_problems_are_reported_as_such() {
let contradictory = LpProblem {
c: vec![1.0],
a: Matrix::from_rows(&[&[1.0], &[1.0]]).unwrap(),
b: vec![1.0, 5.0],
constraint_types: vec![Cmp::Le, Cmp::Ge],
bounds: vec![(0.0, f64::INFINITY)],
maximize: false,
};
assert_eq!(simplex(&contradictory).unwrap(), LpResult::Infeasible);
assert_eq!(contradictory.objective_at(&[3.0]), 3.0);
assert!(simplex(&contradictory).unwrap().solution().is_none());
let open = LpProblem {
c: vec![1.0],
a: Matrix::from_rows(&[&[1.0]]).unwrap(),
b: vec![1.0],
constraint_types: vec![Cmp::Ge],
bounds: vec![(0.0, f64::INFINITY)],
maximize: true,
};
assert_eq!(simplex(&open).unwrap(), LpResult::Unbounded);
assert!(simplex(&open).unwrap().objective().is_none());
}
#[test]
fn bland_s_rule_terminates_on_a_problem_that_cycles_without_it() {
let a = Matrix::from_rows(&[
&[0.5, -5.5, -2.5, 9.0],
&[0.5, -1.5, -0.5, 1.0],
&[1.0, 0.0, 0.0, 0.0],
])
.unwrap();
let p = LpProblem {
c: vec![-10.0, 57.0, 9.0, 24.0],
a,
b: vec![0.0, 0.0, 1.0],
constraint_types: vec![Cmp::Le; 3],
bounds: vec![(0.0, f64::INFINITY); 4],
maximize: false,
};
let r = simplex(&p).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!(p.is_feasible(x, 1e-9), "x = {x:?}");
assert!((objective - -1.0).abs() < 1e-9, "Beale's optimum is -1, got {objective}");
}
#[test]
fn a_degenerate_problem_still_terminates_with_the_right_value() {
let a = Matrix::from_rows(&[&[1.0, 1.0], &[1.0, 0.0], &[0.0, 1.0]]).unwrap();
let p = LpProblem::new(vec![1.0, 1.0], a, vec![2.0, 1.0, 1.0], true).unwrap();
let r = simplex(&p).unwrap();
let (x, objective, _, _) = optimum(&r);
assert!((objective - 2.0).abs() < 1e-9, "objective {objective}, x = {x:?}");
assert!(p.is_feasible(x, 1e-9));
}
#[test]
fn the_dual_reaches_the_same_value_and_carries_the_shadow_prices() {
let p = textbook();
let primal = simplex(&p).unwrap();
let d = lp_dual(&p).unwrap();
let dual = simplex(&d).unwrap();
let (_, po, py, _) = optimum(&primal);
let (dx, dobj, _, _) = optimum(&dual);
assert!((po - dobj).abs() < 1e-9, "primal {po} against dual {dobj}");
for (a, b) in py.iter().zip(dx) {
assert!((a - b).abs() < 1e-9, "shadow prices {py:?} against dual solution {dx:?}");
}
let back = simplex(&lp_dual(&d).unwrap()).unwrap();
assert!((back.objective().unwrap() - po).abs() < 1e-9, "dual of dual gave {back:?}");
assert!(!d.maximize && p.maximize, "the dual did not flip the sense");
}
#[test]
fn strong_duality_and_complementary_slackness_hold_on_random_programs() {
let mut rng = Rng::new(0x_0D0A_0001);
let mut solved = 0usize;
for _ in 0..200 {
let m = 2 + (rng.below(4)) as usize;
let n = 2 + (rng.below(4)) as usize;
let mut a = Matrix::zeros(m, n);
for i in 0..m {
for j in 0..n {
a.set(i, j, (rng.next_f64() * 4.0 - 1.0).round());
}
}
let b: Vec<f64> = (0..m).map(|_| (rng.next_f64() * 20.0 + 1.0).round()).collect();
let c: Vec<f64> = (0..n).map(|_| (rng.next_f64() * 10.0 - 2.0).round()).collect();
let p = LpProblem::new(c, a, b, true).unwrap();
let r = simplex(&p).unwrap();
let LpResult::Optimal { x, objective, duals, reduced_costs } = &r else {
continue;
};
solved += 1;
assert!(p.is_feasible(x, 1e-7), "the reported point is not feasible: {x:?}");
let by: f64 = p.b.iter().zip(duals).map(|(a, b)| a * b).sum();
assert!(
close(by, *objective, 1e-7),
"strong duality failed: b . y = {by}, objective = {objective}"
);
assert!(duals.iter().all(|&v| v > -1e-7), "a shadow price went negative: {duals:?}");
for (j, &xj) in x.iter().enumerate() {
if xj > 1e-7 {
assert!(
reduced_costs[j].abs() < 1e-6,
"variable {j} is in use but has reduced cost {}",
reduced_costs[j]
);
}
}
for i in 0..p.m() {
let row: f64 = (0..p.n()).map(|j| p.a.get(i, j) * x[j]).sum();
if row < p.b[i] - 1e-7 {
assert!(
duals[i].abs() < 1e-6,
"row {i} is slack but priced at {}",
duals[i]
);
}
}
}
assert!(solved > 100, "only {solved} of 200 random programs had an optimum");
}
#[test]
fn weak_duality_bounds_every_feasible_pair() {
let p = textbook();
let d = lp_dual(&p).unwrap();
let mut rng = Rng::new(0x_0D0A_0002);
let LpResult::Optimal { objective, .. } = simplex(&p).unwrap() else {
panic!("expected an optimum");
};
for _ in 0..500 {
let x = vec![rng.next_f64() * 4.0, rng.next_f64() * 6.0];
if p.is_feasible(&x, 0.0) {
assert!(
p.objective_at(&x) <= objective + 1e-9,
"a feasible point beat the optimum"
);
}
let y = vec![rng.next_f64() * 2.0, rng.next_f64() * 2.0, rng.next_f64() * 2.0];
if d.is_feasible(&y, 0.0) {
assert!(
d.objective_at(&y) >= objective - 1e-9,
"a dual-feasible point fell below the optimum"
);
}
}
}
#[test]
fn lp_dual_rejects_a_problem_it_cannot_transpose() {
let mut p = textbook();
p.bounds[0] = (0.0, 5.0);
assert!(lp_dual(&p).is_err(), "a bounded variable should be refused");
p.bounds[0] = (1.0, f64::INFINITY);
assert!(lp_dual(&p).is_err(), "a shifted variable should be refused");
}
#[test]
fn the_two_solvers_agree_on_a_hundred_random_programs() {
let mut rng = Rng::new(0x_0117_0001);
let mut compared = 0usize;
for _ in 0..100 {
let m = 2 + (rng.below(4)) as usize;
let n = 2 + (rng.below(4)) as usize;
let mut a = Matrix::zeros(m, n);
for i in 0..m {
for j in 0..n {
a.set(i, j, (rng.next_f64() * 3.0).round() + 1.0);
}
}
let b: Vec<f64> = (0..m).map(|_| (rng.next_f64() * 20.0 + 5.0).round()).collect();
let c: Vec<f64> = (0..n).map(|_| (rng.next_f64() * 9.0).round() + 1.0).collect();
let p = LpProblem::new(c, a, b, true).unwrap();
let s = simplex(&p).unwrap();
let i = interior_point(&p, 1e-9).unwrap();
let (LpResult::Optimal { objective: so, .. }, LpResult::Optimal { objective: io, x: ix, .. }) =
(&s, &i)
else {
continue;
};
compared += 1;
assert!(
close(*so, *io, 1e-5),
"simplex {so} against interior point {io}"
);
assert!(p.is_feasible(ix, 1e-5), "the interior point answer is infeasible: {ix:?}");
}
assert!(compared > 80, "only {compared} of 100 programs were comparable");
}
#[test]
fn interior_point_handles_the_awkward_shapes_too() {
let p = LpProblem {
c: vec![2.0, 3.0, 1.0],
a: Matrix::from_rows(&[&[1.0, 1.0, 1.0], &[1.0, -1.0, 0.0]]).unwrap(),
b: vec![10.0, 2.0],
constraint_types: vec![Cmp::Eq, Cmp::Ge],
bounds: vec![(0.0, f64::INFINITY), (0.0, f64::INFINITY), (0.0, f64::INFINITY)],
maximize: false,
};
let s = simplex(&p).unwrap();
let i = interior_point(&p, 1e-10).unwrap();
let (_, so, _, _) = optimum(&s);
let (ix, io, _, _) = optimum(&i);
assert!(close(so, io, 1e-5), "simplex {so} against interior point {io}");
assert!(p.is_feasible(ix, 1e-5), "x = {ix:?}");
assert!(interior_point(&p, 0.0).is_err());
assert!(interior_point(&p, -1.0).is_err());
}
#[test]
fn a_right_hand_side_moves_the_objective_at_exactly_its_shadow_price() {
let p = textbook();
let (_, objective, duals, _) = {
let r = simplex(&p).unwrap();
let (x, o, d, rc) = optimum(&r);
(x.to_vec(), o, d.to_vec(), rc.to_vec())
};
let (c_ranges, b_ranges) = sensitivity_ranges(&p).unwrap();
assert_eq!(b_ranges.len(), p.m());
assert_eq!(c_ranges.len(), p.n());
for i in 0..p.m() {
let (lo, hi) = b_ranges[i];
assert!(lo <= p.b[i] + 1e-9 && hi >= p.b[i] - 1e-9, "row {i} range {lo}..{hi}");
for fraction in [0.3f64, 0.7, -0.3, -0.7] {
let span = if fraction > 0.0 { hi - p.b[i] } else { p.b[i] - lo };
if !span.is_finite() || span <= 0.0 {
continue;
}
let delta = fraction.signum() * fraction.abs() * span;
let mut q = p.clone();
q.b[i] += delta;
let moved = simplex(&q).unwrap().objective().unwrap();
assert!(
(moved - objective - duals[i] * delta).abs() < 1e-7,
"row {i}, delta {delta}: objective {moved}, expected {}",
objective + duals[i] * delta
);
}
}
}
#[test]
fn an_objective_coefficient_inside_its_range_leaves_the_solution_put() {
let p = textbook();
let base = simplex(&p).unwrap();
let (x0, _, _, _) = optimum(&base);
let x0 = x0.to_vec();
let (c_ranges, _) = sensitivity_ranges(&p).unwrap();
for j in 0..p.n() {
let (lo, hi) = c_ranges[j];
assert!(lo <= p.c[j] + 1e-9 && hi >= p.c[j] - 1e-9, "coefficient {j} range {lo}..{hi}");
for target in [lo, hi] {
if !target.is_finite() {
continue;
}
let inside = p.c[j] + 0.95 * (target - p.c[j]);
let mut q = p.clone();
q.c[j] = inside;
let moved = simplex(&q).unwrap();
let (x1, o1, _, _) = optimum(&moved);
for (a, b) in x0.iter().zip(x1) {
assert!(
(a - b).abs() < 1e-7,
"coefficient {j} at {inside} moved the solution from {x0:?} to {x1:?}"
);
}
assert!(close(o1, q.objective_at(&x0), 1e-9));
}
}
}
#[test]
fn sensitivity_declines_problems_it_cannot_report_on() {
let mut p = textbook();
p.bounds[0] = (0.0, 5.0);
assert!(sensitivity_ranges(&p).is_err(), "a bounded variable should be refused");
let unbounded = LpProblem {
c: vec![1.0],
a: Matrix::from_rows(&[&[1.0]]).unwrap(),
b: vec![1.0],
constraint_types: vec![Cmp::Ge],
bounds: vec![(0.0, f64::INFINITY)],
maximize: true,
};
assert!(sensitivity_ranges(&unbounded).is_err());
}
#[test]
fn the_dual_simplex_reaches_the_primal_answer_from_an_optimal_basis() {
let p = textbook();
let q = LpProblem {
c: vec![3.0, 5.0],
a: p.a.clone(),
b: vec![4.0, 12.0, 18.0],
constraint_types: vec![Cmp::Ge; 3],
bounds: vec![(0.0, f64::INFINITY); 2],
maximize: false,
};
let fresh = simplex(&q).unwrap();
let (fx, fo, _, _) = optimum(&fresh);
let via_dual = dual_simplex(&q, &[2, 3, 4]).unwrap();
let (dx, dobj, _, _) = optimum(&via_dual);
assert!(close(fo, dobj, 1e-9), "primal {fo} against dual simplex {dobj}");
for (a, b) in fx.iter().zip(dx) {
assert!((a - b).abs() < 1e-7, "points differ: {fx:?} against {dx:?}");
}
}
#[test]
fn the_dual_simplex_rejects_a_basis_it_cannot_start_from() {
let p = textbook();
assert!(dual_simplex(&p, &[0, 1]).is_err(), "a short basis should be refused");
assert!(dual_simplex(&p, &[0, 1, 99]).is_err(), "an out-of-range column should be refused");
assert!(dual_simplex(&p, &[2, 3, 4]).is_err(), "a dual-infeasible basis should be refused");
}
#[test]
fn the_parser_reproduces_a_hand_built_problem() {
let text = "\
max 3x + 5y
subject to
x <= 4
2y <= 12
3x + 2y <= 18
";
let parsed = lp_from_str(text).unwrap();
let built = textbook();
assert_eq!(parsed.c, built.c);
assert_eq!(parsed.b, built.b);
assert_eq!(parsed.constraint_types, built.constraint_types);
assert_eq!(parsed.maximize, built.maximize);
for i in 0..built.m() {
for j in 0..built.n() {
assert!((parsed.a.get(i, j) - built.a.get(i, j)).abs() < 1e-12);
}
}
assert!(close(simplex(&parsed).unwrap().objective().unwrap(), 36.0, 1e-9));
}
#[test]
fn the_parser_handles_signs_senses_comments_and_bounds() {
let text = "\
# a comment, and a blank line follow
min 2a - 3b + c
s.t.
a + b >= 4
a - 2b + 3c = 6 # an equality
-a + b <= 2
bounds
b <= 10
free c
";
let p = lp_from_str(text).unwrap();
assert!(!p.maximize);
assert_eq!(p.c, vec![2.0, -3.0, 1.0]);
assert_eq!(p.constraint_types, vec![Cmp::Ge, Cmp::Eq, Cmp::Le]);
assert_eq!(p.b, vec![4.0, 6.0, 2.0]);
assert_eq!(p.bounds[1], (0.0, 10.0));
assert_eq!(p.bounds[2].0, f64::NEG_INFINITY);
assert!((p.a.get(1, 0) - 1.0).abs() < 1e-12);
assert!((p.a.get(1, 1) + 2.0).abs() < 1e-12);
assert!((p.a.get(1, 2) - 3.0).abs() < 1e-12);
assert!((p.a.get(2, 0) + 1.0).abs() < 1e-12);
let r = simplex(&p).unwrap();
if let LpResult::Optimal { x, .. } = &r {
assert!(p.is_feasible(x, 1e-7), "x = {x:?}");
}
}
#[test]
fn the_parser_reports_what_it_could_not_read() {
assert!(lp_from_str("").is_err());
assert!(lp_from_str("solve 3x").is_err(), "a missing sense should be refused");
assert!(lp_from_str("max 3x\nst\n x + y").is_err(), "a row with no operator");
assert!(lp_from_str("max 3x\nst\n x <= abc").is_err(), "a non-numeric right-hand side");
assert!(lp_from_str("max 3x\nst\n 4 <= 5").is_err(), "a row with no variable");
assert!(
lp_from_str("max 3x\nst\n x <= 4\nbounds\n 2x >= 1").is_err(),
"a bounds line with a coefficient"
);
}
#[test]
fn the_diet_problem_meets_every_requirement_at_least_cost() {
let nutrients = Matrix::from_rows(&[&[1.0, 3.0], &[2.0, 1.0]]).unwrap();
let p = diet_problem(&[1.0, 2.0], &nutrients, &[9.0, 8.0]).unwrap();
let r = simplex(&p).unwrap();
let (x, objective, duals, _) = optimum(&r);
assert!(p.is_feasible(x, 1e-7), "the diet does not meet the requirements: {x:?}");
for k in 0..2 {
let got: f64 = (0..2).map(|j| nutrients.get(k, j) * x[j]).sum();
assert!(got >= 9.0f64.min(8.0) - 1e-7, "nutrient {k} came to {got}");
}
assert!(duals.iter().all(|&v| v > -1e-7), "duals {duals:?}");
let by: f64 = p.b.iter().zip(duals).map(|(a, b)| a * b).sum();
assert!(close(by, objective, 1e-7), "b . y = {by} against {objective}");
assert!(diet_problem(&[1.0], &nutrients, &[9.0, 8.0]).is_err());
assert!(diet_problem(&[1.0, 2.0], &nutrients, &[9.0]).is_err());
}
#[test]
fn a_production_plan_exhausts_the_binding_resource() {
let usage = Matrix::from_rows(&[&[2.0, 1.0], &[1.0, 3.0]]).unwrap();
let p = production_planning(&[5.0, 4.0], &usage, &[100.0, 90.0]).unwrap();
let r = simplex(&p).unwrap();
let (x, objective, duals, _) = optimum(&r);
assert!(p.is_feasible(x, 1e-7));
assert!(objective > 0.0);
for i in 0..2 {
if duals[i] > 1e-7 {
let used: f64 = (0..2).map(|j| usage.get(i, j) * x[j]).sum();
assert!(
(used - p.b[i]).abs() < 1e-7,
"resource {i} is priced at {} but only {used} of {} is used",
duals[i],
p.b[i]
);
}
}
assert!(production_planning(&[5.0], &usage, &[100.0, 90.0]).is_err());
}
#[test]
fn the_transportation_problem_ships_everything_demanded_at_least_cost() {
let costs = Matrix::from_rows(&[&[4.0, 6.0, 9.0], &[5.0, 3.0, 8.0], &[7.0, 7.0, 2.0]])
.unwrap();
let supply = [30.0, 40.0, 50.0];
let demand = [25.0, 35.0, 45.0];
let r = transportation_problem(&supply, &demand, &costs).unwrap();
let (x, objective, _, _) = optimum(&r);
for i in 0..3 {
let shipped: f64 = (0..3).map(|j| x[i * 3 + j]).sum();
assert!(shipped <= supply[i] + 1e-7, "source {i} over-shipped {shipped}");
}
for j in 0..3 {
let received: f64 = (0..3).map(|i| x[i * 3 + j]).sum();
assert!(received >= demand[j] - 1e-7, "sink {j} received only {received}");
}
let greedy_bound: f64 = (0..3)
.map(|j| {
let cheapest =
(0..3).map(|i| costs.get(i, j)).fold(f64::INFINITY, f64::min);
cheapest * demand[j]
})
.sum();
assert!(objective >= greedy_bound - 1e-7, "{objective} beat the bound {greedy_bound}");
assert!(objective <= 1e6);
for v in x {
assert!((v - v.round()).abs() < 1e-7, "a shipment came out fractional: {v}");
}
assert_eq!(
transportation_problem(&[1.0], &[5.0], &Matrix::from_rows(&[&[1.0]]).unwrap())
.unwrap(),
LpResult::Infeasible
);
assert!(transportation_problem(&[1.0, 2.0], &[1.0], &costs).is_err());
assert!(transportation_problem(&[-1.0], &[1.0], &Matrix::from_rows(&[&[1.0]]).unwrap())
.is_err());
}
#[test]
fn matching_pennies_is_fair_and_played_uniformly() {
let payoff = Matrix::from_rows(&[&[1.0, -1.0], &[-1.0, 1.0]]).unwrap();
let (row, column, value) = two_player_zero_sum_lp(&payoff).unwrap();
assert!(value.abs() < 1e-9, "the value should be zero, got {value}");
for v in &row {
assert!((v - 0.5).abs() < 1e-9, "row strategy {row:?}");
}
for v in &column {
assert!((v - 0.5).abs() < 1e-9, "column strategy {column:?}");
}
assert!(close(row.iter().sum::<f64>(), 1.0, 1e-9));
assert!(close(column.iter().sum::<f64>(), 1.0, 1e-9));
}
#[test]
fn rock_paper_scissors_is_fair_and_played_uniformly() {
let payoff = Matrix::from_rows(&[
&[0.0, -1.0, 1.0],
&[1.0, 0.0, -1.0],
&[-1.0, 1.0, 0.0],
])
.unwrap();
let (row, column, value) = two_player_zero_sum_lp(&payoff).unwrap();
assert!(value.abs() < 1e-9, "value {value}");
for v in row.iter().chain(&column) {
assert!((v - 1.0 / 3.0).abs() < 1e-9, "row {row:?} column {column:?}");
}
}
#[test]
fn the_minimax_value_is_what_both_players_can_guarantee() {
let payoff = Matrix::from_rows(&[&[3.0, -1.0, 2.0], &[-2.0, 4.0, 0.0]]).unwrap();
let (row, column, value) = two_player_zero_sum_lp(&payoff).unwrap();
assert!(close(row.iter().sum::<f64>(), 1.0, 1e-9), "row {row:?}");
assert!(close(column.iter().sum::<f64>(), 1.0, 1e-9), "column {column:?}");
assert!(row.iter().chain(&column).all(|&v| v > -1e-9), "a negative probability");
for j in 0..payoff.cols {
let got: f64 = (0..payoff.rows).map(|i| row[i] * payoff.get(i, j)).sum();
assert!(got >= value - 1e-7, "column {j} held the row player to {got} below {value}");
}
for i in 0..payoff.rows {
let got: f64 = (0..payoff.cols).map(|j| column[j] * payoff.get(i, j)).sum();
assert!(got <= value + 1e-7, "row {i} earned {got} above {value}");
}
let saddle = Matrix::from_rows(&[&[4.0, 5.0], &[2.0, 3.0]]).unwrap();
let (r2, _, v2) = two_player_zero_sum_lp(&saddle).unwrap();
assert!((v2 - 4.0).abs() < 1e-9, "the saddle value is 4, got {v2}");
assert!((r2[0] - 1.0).abs() < 1e-9, "the row player should play row 0: {r2:?}");
let trivial = Matrix::from_rows(&[&[7.0]]).unwrap();
let (r, c, v) = two_player_zero_sum_lp(&trivial).unwrap();
assert!((v - 7.0).abs() < 1e-9 && (r[0] - 1.0).abs() < 1e-9 && (c[0] - 1.0).abs() < 1e-9);
}
#[test]
fn the_chebyshev_centre_of_a_box_is_its_middle() {
let a = Matrix::from_rows(&[
&[1.0, 0.0],
&[-1.0, 0.0],
&[0.0, 1.0],
&[0.0, -1.0],
])
.unwrap();
let (centre, radius) = chebyshev_center(&a, &[4.0, 0.0, 6.0, 0.0]).unwrap();
assert!((radius - 2.0).abs() < 1e-9, "radius {radius}");
assert!((centre[0] - 2.0).abs() < 1e-9, "centre {centre:?}");
assert!(
(2.0..=4.0).contains(¢re[1]),
"centre {centre:?} puts the circle outside the box"
);
for i in 0..4 {
let norm: f64 = (0..2).map(|j| a.get(i, j) * a.get(i, j)).sum::<f64>().sqrt();
let slack = [4.0, 0.0, 6.0, 0.0][i]
- (0..2).map(|j| a.get(i, j) * centre[j]).sum::<f64>();
assert!(slack / norm >= radius - 1e-7, "face {i} is only {} away", slack / norm);
}
let half = Matrix::from_rows(&[&[1.0, 0.0]]).unwrap();
assert!(chebyshev_center(&half, &[1.0]).unwrap().1.is_infinite());
let empty = Matrix::from_rows(&[&[1.0], &[-1.0]]).unwrap();
assert!(chebyshev_center(&empty, &[-1.0, -1.0]).is_err());
assert!(chebyshev_center(&Matrix::zeros(1, 2), &[1.0]).is_err());
assert!(chebyshev_center(&a, &[1.0]).is_err());
}
#[test]
fn the_l1_fit_shrugs_off_an_outlier_that_drags_least_squares() {
let n = 21usize;
let mut design = Matrix::zeros(n, 2);
let mut y = vec![0.0; n];
for i in 0..n {
let t = i as f64;
design.set(i, 0, 1.0);
design.set(i, 1, t);
y[i] = 3.0 + 2.0 * t;
}
y[10] += 100.0;
let l1 = l1_regression_lp(&design, &y).unwrap();
assert!((l1[0] - 3.0).abs() < 1e-6, "intercept {} should be 3", l1[0]);
assert!((l1[1] - 2.0).abs() < 1e-6, "slope {} should be 2", l1[1]);
let l2 = crate::linalg::qr::least_squares(&design, &y).unwrap();
assert!(
(l2[0] - 3.0).abs() > 1.0,
"least squares was supposed to be dragged, got {l2:?}"
);
let cost = |beta: &[f64]| -> f64 {
(0..n)
.map(|i| (y[i] - beta[0] - beta[1] * design.get(i, 1)).abs())
.sum()
};
assert!(cost(&l1) <= cost(&l2) + 1e-7, "L1 {} against L2 {}", cost(&l1), cost(&l2));
assert!(l1_regression_lp(&design, &y[..3]).is_err());
}
#[test]
fn the_minimax_fit_equalises_its_largest_residuals() {
let n = 12usize;
let mut design = Matrix::zeros(n, 2);
let mut y = vec![0.0; n];
for i in 0..n {
let t = i as f64;
design.set(i, 0, 1.0);
design.set(i, 1, t);
y[i] = 1.0 + 0.5 * t + (t * 1.7).sin();
}
let beta = linf_regression_lp(&design, &y).unwrap();
let residuals: Vec<f64> =
(0..n).map(|i| y[i] - beta[0] - beta[1] * design.get(i, 1)).collect();
let worst = residuals.iter().map(|r| r.abs()).fold(0.0f64, f64::max);
let attained = residuals.iter().filter(|r| (r.abs() - worst).abs() < 1e-7).count();
assert!(attained >= 3, "only {attained} residuals reached the maximum {worst}");
let extremes: Vec<f64> =
residuals.iter().copied().filter(|r| (r.abs() - worst).abs() < 1e-7).collect();
assert!(
extremes.iter().any(|&v| v > 0.0) && extremes.iter().any(|&v| v < 0.0),
"the extreme residuals do not alternate in sign: {extremes:?}"
);
let l1 = l1_regression_lp(&design, &y).unwrap();
let l1_worst = (0..n)
.map(|i| (y[i] - l1[0] - l1[1] * design.get(i, 1)).abs())
.fold(0.0f64, f64::max);
assert!(worst <= l1_worst + 1e-7, "minimax {worst} against L1's worst {l1_worst}");
assert!(linf_regression_lp(&design, &y[..3]).is_err());
}
#[test]
fn malformed_problems_are_refused_rather_than_solved() {
let a = Matrix::from_rows(&[&[1.0, 2.0]]).unwrap();
assert!(LpProblem::new(vec![1.0], a.clone(), vec![1.0], false).is_err());
assert!(LpProblem::new(vec![1.0, 2.0], a.clone(), vec![1.0, 2.0], false).is_err());
let empty = LpProblem {
c: Vec::new(),
a: Matrix::from_rows(&[&[1.0]]).unwrap(),
b: vec![1.0],
constraint_types: vec![Cmp::Le],
bounds: Vec::new(),
maximize: false,
};
assert!(empty.validate().is_err(), "a problem with no variables should be refused");
let mut p = LpProblem::new(vec![1.0, 2.0], a, vec![1.0], false).unwrap();
p.bounds[0] = (5.0, 1.0);
assert!(p.validate().is_err(), "an inverted bound should be refused");
p.bounds[0] = (0.0, f64::INFINITY);
p.constraint_types.push(Cmp::Le);
assert!(p.validate().is_err(), "a spare constraint sense should be refused");
let mut q = textbook();
q.c[0] = f64::NAN;
assert!(q.validate().is_err(), "a non-finite coefficient should be refused");
assert!(!q.is_feasible(&[1.0], 1e-9), "a wrong-length point is not feasible");
}
}