Skip to main content

numra_optim/
lp.rs

1//! Revised Simplex method for Linear Programming.
2//!
3//! Solves: minimize c^T x subject to A_ineq x <= b_ineq, A_eq x = b_eq, x >= 0.
4//!
5//! Uses two-phase simplex: Phase I finds a basic feasible solution,
6//! Phase II optimizes the objective.
7//!
8//! Author: Moussa Leblouba
9//! Date: 8 February 2026
10//! Modified: 2 May 2026
11
12use crate::error::OptimError;
13use crate::types::{IterationRecord, OptimResult, OptimStatus};
14use numra_core::Scalar;
15use numra_linalg::{DenseMatrix, Matrix};
16
17/// Options for the LP solver.
18#[derive(Clone, Debug)]
19pub struct LPOptions<S: Scalar> {
20    pub max_iter: usize,
21    pub tol: S,
22    pub verbose: bool,
23}
24
25impl<S: Scalar> Default for LPOptions<S> {
26    fn default() -> Self {
27        Self {
28            max_iter: 10_000,
29            tol: S::from_f64(1e-10),
30            verbose: false,
31        }
32    }
33}
34
35/// Solve a linear program using the Revised Simplex method.
36///
37/// # Standard form conversion
38///
39/// The user provides:
40/// - `c`: objective coefficients (minimize c^T x)
41/// - `a_ineq`: rows of A for inequality constraints A_ineq x <= b_ineq
42/// - `b_ineq`: RHS of inequality constraints
43/// - `a_eq`: rows of A for equality constraints A_eq x = b_eq
44/// - `b_eq`: RHS of equality constraints
45/// - All variables assumed non-negative (x >= 0)
46///
47/// Internally converts to standard form by adding slack variables:
48///   minimize  c^T x
49///   subject to [A_eq; A_ineq | I] [x; s] = [b_eq; b_ineq], x >= 0, s >= 0
50pub fn simplex_solve<
51    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
52>(
53    c: &[S],
54    a_ineq: &[Vec<S>],
55    b_ineq: &[S],
56    a_eq: &[Vec<S>],
57    b_eq: &[S],
58    opts: &LPOptions<S>,
59) -> Result<OptimResult<S>, OptimError> {
60    let start = std::time::Instant::now();
61    let n_orig = c.len();
62    let m_ineq = a_ineq.len();
63    let m_eq = a_eq.len();
64    let m = m_ineq + m_eq;
65
66    if m == 0 && n_orig > 0 {
67        // No constraints: unbounded if any c_j < 0, else optimal at x = 0
68        if c.iter().any(|&ci| ci < -opts.tol) {
69            return Err(OptimError::Unbounded);
70        }
71        let x = vec![S::ZERO; n_orig];
72        return Ok(OptimResult::unconstrained(
73            x,
74            S::ZERO,
75            c.to_vec(),
76            0,
77            0,
78            0,
79            true,
80            "Optimal at origin (no constraints)".into(),
81            OptimStatus::FunctionConverged,
82        )
83        .with_wall_time(start));
84    }
85
86    // Build standard-form tableau
87    let n_slack = m_ineq;
88    let n_total = n_orig + n_slack;
89
90    // Validate and build constraint rows with slacks
91    let mut a_rows: Vec<Vec<S>> = Vec::with_capacity(m);
92    let mut b_std: Vec<S> = Vec::with_capacity(m);
93
94    // Equality constraints first
95    for i in 0..m_eq {
96        if a_eq[i].len() != n_orig {
97            return Err(OptimError::DimensionMismatch {
98                expected: n_orig,
99                actual: a_eq[i].len(),
100            });
101        }
102        let mut row = a_eq[i].clone();
103        row.resize(n_total, S::ZERO);
104        let mut bi = b_eq[i];
105        if bi < S::ZERO {
106            for r in row.iter_mut() {
107                *r = -*r;
108            }
109            bi = -bi;
110        }
111        a_rows.push(row);
112        b_std.push(bi);
113    }
114
115    // Inequality constraints: add slacks
116    for i in 0..m_ineq {
117        if a_ineq[i].len() != n_orig {
118            return Err(OptimError::DimensionMismatch {
119                expected: n_orig,
120                actual: a_ineq[i].len(),
121            });
122        }
123        let mut row = a_ineq[i].clone();
124        row.resize(n_total, S::ZERO);
125        row[n_orig + i] = S::ONE;
126        let mut bi = b_ineq[i];
127        if bi < S::ZERO {
128            for r in row.iter_mut() {
129                *r = -*r;
130            }
131            bi = -bi;
132        }
133        a_rows.push(row);
134        b_std.push(bi);
135    }
136
137    // Extended cost vector
138    let mut c_std = c.to_vec();
139    c_std.resize(n_total, S::ZERO);
140
141    // Determine if Phase I is needed
142    let mut need_art = vec![false; m];
143    for item in need_art.iter_mut().take(m_eq) {
144        *item = true;
145    }
146    let has_artificials = need_art.iter().any(|&x| x);
147
148    let mut basis: Vec<usize> = Vec::with_capacity(m);
149
150    if has_artificials {
151        // Phase I: add artificial variables for equality rows
152        let n_phase1 = n_total + m;
153        let mut a_ph1: Vec<Vec<S>> = Vec::with_capacity(m);
154        for (i, row) in a_rows.iter().enumerate() {
155            let mut ph1_row = row.clone();
156            ph1_row.resize(n_phase1, S::ZERO);
157            if need_art[i] {
158                ph1_row[n_total + i] = S::ONE;
159            }
160            a_ph1.push(ph1_row);
161        }
162
163        let mut c_ph1 = vec![S::ZERO; n_phase1];
164        for i in 0..m {
165            if need_art[i] {
166                c_ph1[n_total + i] = S::ONE;
167            }
168        }
169
170        let mut basis_ph1 = Vec::with_capacity(m);
171        for i in 0..m_eq {
172            basis_ph1.push(n_total + i); // artificial variables for equality rows
173        }
174        for i in 0..m_ineq {
175            basis_ph1.push(n_orig + i); // slack variables for inequality rows
176        }
177
178        let ph1_result = simplex_core(&a_ph1, &mut b_std, &c_ph1, &mut basis_ph1, opts)?;
179
180        if ph1_result > opts.tol {
181            return Err(OptimError::LPInfeasible);
182        }
183
184        // Check no artificial is in basis at nonzero level
185        for &bj in &basis_ph1 {
186            if bj >= n_total {
187                let row = basis_ph1.iter().position(|&x| x == bj).unwrap();
188                if b_std[row].abs() > opts.tol {
189                    return Err(OptimError::LPInfeasible);
190                }
191            }
192        }
193
194        basis = basis_ph1;
195        // Replace artificial basis variables with real ones
196        for row in 0..m {
197            if basis[row] >= n_total {
198                for (j, val) in a_rows[row].iter().enumerate().take(n_total) {
199                    if !basis.contains(&j) && val.abs() > opts.tol {
200                        basis[row] = j;
201                        break;
202                    }
203                }
204            }
205        }
206    } else {
207        // No equality constraints: slack variables form the initial basis
208        for i in 0..m_ineq {
209            basis.push(n_orig + i);
210        }
211    }
212
213    // Phase II: optimize the actual objective
214    let _opt_val = simplex_core(&a_rows, &mut b_std, &c_std, &mut basis, opts)?;
215
216    // Extract solution
217    let mut x = vec![S::ZERO; n_total];
218    for (row, &bj) in basis.iter().enumerate() {
219        if bj < n_total {
220            x[bj] = b_std[row];
221        }
222    }
223
224    let x_orig: Vec<S> = x[..n_orig].to_vec();
225    let f_val: S = c
226        .iter()
227        .zip(x_orig.iter())
228        .map(|(&ci, &xi)| ci * xi)
229        .sum::<S>();
230
231    // Compute dual variables (pi = c_B^T * B^{-1})
232    let mut b_mat = DenseMatrix::<S>::zeros(m, m);
233    for (col, &bj) in basis.iter().enumerate() {
234        for (i, a_row) in a_rows.iter().enumerate().take(m) {
235            b_mat.set(i, col, a_row[bj]);
236        }
237    }
238    let c_b: Vec<S> = basis.iter().map(|&j| c_std[j]).collect();
239    // Solve B^T * pi = c_B
240    let mut bt = DenseMatrix::<S>::zeros(m, m);
241    for i in 0..m {
242        for j in 0..m {
243            bt.set(i, j, b_mat.get(j, i));
244        }
245    }
246    let pi = bt.solve(&c_b).unwrap_or_else(|_| vec![S::ZERO; m]);
247
248    let mut lambda_eq = Vec::with_capacity(m_eq);
249    let mut lambda_ineq = Vec::with_capacity(m_ineq);
250    for item in pi.iter().take(m_eq) {
251        lambda_eq.push(*item);
252    }
253    for i in 0..m_ineq {
254        lambda_ineq.push(pi[m_eq + i]);
255    }
256
257    let history = vec![IterationRecord {
258        iteration: 0,
259        objective: f_val,
260        gradient_norm: S::ZERO,
261        step_size: S::ZERO,
262        constraint_violation: S::ZERO,
263    }];
264
265    Ok((OptimResult {
266        lambda_eq,
267        lambda_ineq,
268        history,
269        ..OptimResult::unconstrained(
270            x_orig,
271            f_val,
272            c.to_vec(),
273            0,
274            0,
275            0,
276            true,
277            "Optimal solution found".into(),
278            OptimStatus::FunctionConverged,
279        )
280    })
281    .with_wall_time(start))
282}
283
284/// Core revised simplex iteration.
285///
286/// Performs pivot operations on the basis until optimality is reached or
287/// unboundedness / max iterations is detected.
288///
289/// Returns the optimal objective value on success.
290fn simplex_core<
291    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
292>(
293    a_rows: &[Vec<S>],
294    b: &mut [S],
295    c: &[S],
296    basis: &mut [usize],
297    opts: &LPOptions<S>,
298) -> Result<S, OptimError> {
299    let m = a_rows.len();
300    let n = c.len();
301
302    for _iter in 0..opts.max_iter {
303        // Build basis matrix B
304        let mut b_mat = DenseMatrix::<S>::zeros(m, m);
305        for (col, &bj) in basis.iter().enumerate() {
306            for (row, a_row) in a_rows.iter().enumerate().take(m) {
307                b_mat.set(row, col, a_row[bj]);
308            }
309        }
310
311        let c_b: Vec<S> = basis.iter().map(|&j| c[j]).collect();
312
313        // Solve B^T * pi = c_B for dual variables
314        let mut bt = DenseMatrix::<S>::zeros(m, m);
315        for i in 0..m {
316            for j in 0..m {
317                bt.set(i, j, b_mat.get(j, i));
318            }
319        }
320        let pi = bt.solve(&c_b).map_err(|_| OptimError::SingularMatrix)?;
321
322        // Compute reduced costs and find entering variable (most negative)
323        let mut entering = None;
324        let mut min_rc = -opts.tol;
325        for j in 0..n {
326            if basis.contains(&j) {
327                continue;
328            }
329            let mut rc = c[j];
330            for i in 0..m {
331                rc -= pi[i] * a_rows[i][j];
332            }
333            if rc < min_rc {
334                min_rc = rc;
335                entering = Some(j);
336            }
337        }
338
339        let entering = match entering {
340            Some(j) => j,
341            None => {
342                // All reduced costs >= 0: optimal
343                return Ok(basis
344                    .iter()
345                    .enumerate()
346                    .map(|(row, &j)| c[j] * b[row])
347                    .sum::<S>());
348            }
349        };
350
351        // Solve B * d = a_entering for the direction
352        let a_col: Vec<S> = (0..m).map(|i| a_rows[i][entering]).collect();
353        let d = b_mat
354            .solve(&a_col)
355            .map_err(|_| OptimError::SingularMatrix)?;
356
357        // Minimum ratio test to find leaving variable
358        let mut min_ratio = S::INFINITY;
359        let mut leaving_row = None;
360        for i in 0..m {
361            if d[i] > opts.tol {
362                let ratio = b[i] / d[i];
363                if ratio < min_ratio {
364                    min_ratio = ratio;
365                    leaving_row = Some(i);
366                }
367            }
368        }
369
370        let leaving_row = match leaving_row {
371            Some(r) => r,
372            None => return Err(OptimError::Unbounded),
373        };
374
375        // Update RHS and basis
376        let theta = min_ratio;
377        for i in 0..m {
378            if i == leaving_row {
379                b[i] = theta;
380            } else {
381                b[i] -= theta * d[i];
382            }
383        }
384        basis[leaving_row] = entering;
385
386        // Clamp tiny negatives to zero (numerical cleanup)
387        for bi in b.iter_mut() {
388            if *bi < S::ZERO && *bi > -opts.tol {
389                *bi = S::ZERO;
390            }
391        }
392    }
393
394    Err(OptimError::Other(format!(
395        "simplex: max iterations ({}) reached",
396        opts.max_iter
397    )))
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_lp_simple_2d() {
406        // minimize -x1 - x2 subject to x1+x2<=4, x1<=3, x2<=3, x>=0
407        let c = vec![-1.0, -1.0];
408        let a_ineq = vec![vec![1.0, 1.0], vec![1.0, 0.0], vec![0.0, 1.0]];
409        let b_ineq = vec![4.0, 3.0, 3.0];
410        let opts = LPOptions::default();
411        let result = simplex_solve(&c, &a_ineq, &b_ineq, &[], &[], &opts).unwrap();
412        assert!(result.converged, "LP did not converge: {}", result.message);
413        assert!(
414            (result.f - (-4.0)).abs() < 1e-8,
415            "f={}, expected -4",
416            result.f
417        );
418        assert!(result.x[0] + result.x[1] - 4.0 < 1e-8);
419    }
420
421    #[test]
422    fn test_lp_with_equality() {
423        // minimize x1 + 2*x2 subject to x1+x2=3, x>=0
424        let c = vec![1.0, 2.0];
425        let a_eq = vec![vec![1.0, 1.0]];
426        let b_eq = vec![3.0];
427        let opts = LPOptions::default();
428        let result = simplex_solve(&c, &[], &[], &a_eq, &b_eq, &opts).unwrap();
429        assert!(result.converged, "LP did not converge: {}", result.message);
430        assert!((result.f - 3.0).abs() < 1e-8, "f={}", result.f);
431        assert!((result.x[0] - 3.0).abs() < 1e-8);
432        assert!(result.x[1].abs() < 1e-8);
433    }
434
435    #[test]
436    fn test_lp_unbounded() {
437        // minimize -x with no constraints => unbounded
438        let c = vec![-1.0];
439        let opts = LPOptions::default();
440        let result = simplex_solve(&c, &[], &[], &[], &[], &opts);
441        assert!(result.is_err());
442    }
443
444    #[test]
445    fn test_lp_infeasible() {
446        // x >= 0 and x <= -1 is infeasible (after negation: -x <= 1, so x >= -1
447        // but we also need the constraint to truly be infeasible)
448        // Actually: x <= -1 means a_ineq = [1], b_ineq = [-1]
449        // After negation of negative RHS: -x <= 1, i.e. x >= -1
450        // With x >= 0 this is feasible at x=0. We need a different infeasible setup.
451        // Use equality: x = -1 with x >= 0 => infeasible
452        // Use a clearly infeasible system: x1+x2=1, x1+x2=2
453        let c2 = vec![1.0, 1.0];
454        let a_eq2 = vec![vec![1.0, 1.0], vec![1.0, 1.0]];
455        let b_eq2 = vec![1.0, 2.0];
456        let opts = LPOptions::default();
457        let result = simplex_solve(&c2, &[], &[], &a_eq2, &b_eq2, &opts);
458        assert!(result.is_err());
459    }
460
461    #[test]
462    fn test_lp_3d_production() {
463        // Production planning: maximize 5x1+4x2+3x3 => minimize -5x1-4x2-3x3
464        // subject to 6x1+4x2+2x3<=240, 3x1+2x2+5x3<=270, x>=0
465        let c = vec![-5.0, -4.0, -3.0];
466        let a_ineq = vec![vec![6.0, 4.0, 2.0], vec![3.0, 2.0, 5.0]];
467        let b_ineq = vec![240.0, 270.0];
468        let opts = LPOptions::default();
469        let result = simplex_solve(&c, &a_ineq, &b_ineq, &[], &[], &opts).unwrap();
470        assert!(result.converged, "LP did not converge: {}", result.message);
471        assert!(result.f <= -219.0, "f={}, expected <= -219", result.f);
472    }
473
474    #[test]
475    fn test_lp_dual_variables() {
476        // minimize -x1 - x2 subject to x1+x2<=1, x>=0
477        let c = vec![-1.0, -1.0];
478        let a_ineq = vec![vec![1.0, 1.0]];
479        let b_ineq = vec![1.0];
480        let opts = LPOptions::default();
481        let result = simplex_solve(&c, &a_ineq, &b_ineq, &[], &[], &opts).unwrap();
482        assert!(result.converged);
483        assert!((result.f - (-1.0)).abs() < 1e-8, "f={}", result.f);
484        assert!(!result.lambda_ineq.is_empty());
485    }
486}