Skip to main content

oxiz_math/
simplex_solver.rs

1//! SimplexSolver — LP solver for parametric analysis.
2//!
3//! This module provides a high-level LP interface used by
4//! `simplex_parametric` for sensitivity analysis and parametric
5//! optimization.
6//!
7//! # Algorithm
8//!
9//! Uses the Big-M primal simplex method with exact `BigRational` arithmetic.
10//!
11//! ## Standard Form Conversion
12//!
13//! For each constraint `a'x ≤ b`, `a'x ≥ b`, or `a'x = b`:
14//!
15//! - **Le**: add slack s ≥ 0  →  a'x + s = b  (s is initial basic variable)
16//! - **Ge**: add surplus s ≥ 0 and artificial a ≥ 0  →  a'x - s + a = b
17//! - **Eq**: add artificial a ≥ 0  →  a'x + a = b
18//!
19//! Artificials appear in the objective with coefficient +M (Big-M method) so
20//! the primal simplex drives them to zero.  Once all artificials = 0, the
21//! basis is feasible and the true objective is minimised.
22//!
23//! ## Shadow Prices
24//!
25//! Shadow prices are extracted from the objective row of the final tableau
26//! at the columns corresponding to slack variables.  For a minimisation LP
27//! in standard form the shadow price of constraint i is −(reduced cost of
28//! its slack variable s_i), which equals the dual variable π_i.
29
30use num_bigint::BigInt;
31use num_rational::BigRational;
32use num_traits::{Signed, Zero};
33use thiserror::Error;
34
35// ── public types ─────────────────────────────────────────────────────────────
36
37/// Error type for `SimplexSolver`.
38#[derive(Debug, Error, Clone)]
39pub enum SimplexError {
40    /// Index out of bounds.
41    #[error("index {index} out of bounds (len {len})")]
42    IndexOutOfBounds {
43        /// The requested index.
44        index: usize,
45        /// The length of the collection.
46        len: usize,
47    },
48
49    /// Shadow price queried before solving.
50    #[error("shadow_price called before solve()")]
51    NotYetSolved,
52
53    /// The LP is infeasible so shadow prices are undefined.
54    #[error("shadow_price undefined: problem is infeasible")]
55    Infeasible,
56
57    /// The LP is unbounded so shadow prices are undefined.
58    #[error("shadow_price undefined: problem is unbounded")]
59    Unbounded,
60}
61
62/// Sense of a linear constraint.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ConstraintKind {
65    /// `a'x ≤ b`
66    Le,
67    /// `a'x ≥ b`
68    Ge,
69    /// `a'x = b`
70    Eq,
71}
72
73/// A linear constraint `a'x {≤|≥|=} b`.
74#[derive(Debug, Clone)]
75pub struct Constraint {
76    /// Coefficients for each decision variable (length == `n_vars`).
77    pub coefficients: Vec<BigRational>,
78    /// Right-hand side.
79    pub rhs: BigRational,
80    /// Sense of the constraint.
81    pub kind: ConstraintKind,
82}
83
84impl Constraint {
85    /// Construct a constraint from integer coefficients (convenience).
86    pub fn le(coeffs: impl Into<Vec<BigRational>>, rhs: BigRational) -> Self {
87        Self {
88            coefficients: coeffs.into(),
89            rhs,
90            kind: ConstraintKind::Le,
91        }
92    }
93
94    /// Construct a `≥` constraint.
95    pub fn ge(coeffs: impl Into<Vec<BigRational>>, rhs: BigRational) -> Self {
96        Self {
97            coefficients: coeffs.into(),
98            rhs,
99            kind: ConstraintKind::Ge,
100        }
101    }
102
103    /// Construct an equality constraint.
104    pub fn eq(coeffs: impl Into<Vec<BigRational>>, rhs: BigRational) -> Self {
105        Self {
106            coefficients: coeffs.into(),
107            rhs,
108            kind: ConstraintKind::Eq,
109        }
110    }
111}
112
113/// Status of a solve.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum SolveStatus {
116    /// Optimal solution found.
117    Optimal,
118    /// Problem is infeasible.
119    Infeasible,
120    /// Problem is unbounded.
121    Unbounded,
122}
123
124/// Result returned by `SimplexSolver::solve`.
125#[derive(Debug, Clone)]
126pub struct SolveResult {
127    /// Termination status.
128    pub status: SolveStatus,
129    /// Optimal values for each decision variable.
130    pub values: Vec<BigRational>,
131    /// Optimal objective value (valid only when `status == Optimal`).
132    pub objective: BigRational,
133    /// Shadow price (dual variable) for each original constraint.
134    /// Valid only when `status == Optimal`.
135    pub shadow_prices: Vec<BigRational>,
136}
137
138// ── main struct ──────────────────────────────────────────────────────────────
139
140/// High-level LP solver for parametric analysis.
141///
142/// Minimises `c'x` subject to a set of [`Constraint`]s and `x ≥ 0`.
143#[derive(Debug, Clone)]
144pub struct SimplexSolver {
145    /// Number of decision variables.
146    n_vars: usize,
147    /// Objective coefficients (length == `n_vars`).
148    obj_coeffs: Vec<BigRational>,
149    /// Constraints (each has `coefficients.len() == n_vars`).
150    constraints: Vec<Constraint>,
151    /// Cached result of the last `solve()` call, `None` if not yet solved.
152    last_result: Option<SolveResult>,
153}
154
155impl SimplexSolver {
156    /// Create a solver with the given objective and constraints.
157    ///
158    /// `obj_coeffs[i]` is the coefficient of variable i in the minimised
159    /// objective `c'x`.  All decision variables are implicitly non-negative.
160    pub fn new(obj_coeffs: Vec<BigRational>, constraints: Vec<Constraint>) -> Self {
161        let n_vars = obj_coeffs.len();
162        Self {
163            n_vars,
164            obj_coeffs,
165            constraints,
166            last_result: None,
167        }
168    }
169
170    /// Update a single objective coefficient.
171    ///
172    /// Returns an error when `i ≥ n_vars`.
173    pub fn set_objective_coefficient(
174        &mut self,
175        i: usize,
176        val: BigRational,
177    ) -> Result<(), SimplexError> {
178        if i >= self.n_vars {
179            return Err(SimplexError::IndexOutOfBounds {
180                index: i,
181                len: self.n_vars,
182            });
183        }
184        self.obj_coeffs[i] = val;
185        // Invalidate cached result since objective changed.
186        self.last_result = None;
187        Ok(())
188    }
189
190    /// Get objective coefficient `i`.
191    pub fn get_objective_coefficient(&self, i: usize) -> Result<&BigRational, SimplexError> {
192        self.obj_coeffs
193            .get(i)
194            .ok_or(SimplexError::IndexOutOfBounds {
195                index: i,
196                len: self.n_vars,
197            })
198    }
199
200    /// Get the RHS of constraint `i`.
201    pub fn get_rhs(&self, i: usize) -> Result<&BigRational, SimplexError> {
202        self.constraints
203            .get(i)
204            .map(|c| &c.rhs)
205            .ok_or(SimplexError::IndexOutOfBounds {
206                index: i,
207                len: self.constraints.len(),
208            })
209    }
210
211    /// Set the RHS of constraint `i`.
212    pub fn set_rhs(&mut self, i: usize, val: BigRational) -> Result<(), SimplexError> {
213        let len = self.constraints.len();
214        self.constraints
215            .get_mut(i)
216            .ok_or(SimplexError::IndexOutOfBounds { index: i, len })?
217            .rhs = val;
218        self.last_result = None;
219        Ok(())
220    }
221
222    /// Return the shadow price (dual variable) for constraint `i`.
223    ///
224    /// The shadow price represents how much the optimal objective improves
225    /// (decreases, since we minimise) per unit increase in `b_i`.
226    ///
227    /// Returns an error if `solve()` has not been called yet, or if the
228    /// last solve did not yield an optimal solution.
229    pub fn shadow_price(&self, i: usize) -> Result<BigRational, SimplexError> {
230        let result = self
231            .last_result
232            .as_ref()
233            .ok_or(SimplexError::NotYetSolved)?;
234
235        match result.status {
236            SolveStatus::Infeasible => return Err(SimplexError::Infeasible),
237            SolveStatus::Unbounded => return Err(SimplexError::Unbounded),
238            SolveStatus::Optimal => {}
239        }
240
241        result
242            .shadow_prices
243            .get(i)
244            .cloned()
245            .ok_or(SimplexError::IndexOutOfBounds {
246                index: i,
247                len: result.shadow_prices.len(),
248            })
249    }
250
251    /// Return the cached objective value from the last solve.
252    pub fn objective_value(&self) -> Option<BigRational> {
253        self.last_result
254            .as_ref()
255            .filter(|r| r.status == SolveStatus::Optimal)
256            .map(|r| r.objective.clone())
257    }
258
259    /// Solve the LP via Big-M primal simplex.
260    ///
261    /// Converts to standard equality form, appends artificial variables with
262    /// Big-M objective penalty, and runs the primal simplex until optimal or
263    /// detected infeasible / unbounded.  Shadow prices are read directly from
264    /// the final objective row (reduced costs of slack columns).
265    ///
266    /// Stores the result internally so that subsequent `shadow_price()` calls
267    /// do not require re-solving.
268    pub fn solve(&mut self) -> Result<SolveResult, SimplexError> {
269        let result = self.run_bigm_simplex(&self.obj_coeffs.clone(), &self.constraints.clone());
270        self.last_result = Some(result.clone());
271        Ok(result)
272    }
273
274    /// Build and run the Big-M primal simplex.
275    ///
276    /// Column layout of tableau T (m rows × (total_cols + 1) cols):
277    ///   x_0 … x_{n-1} | s_0 … s_{m-1} | a_0 … a_{k-1} | RHS
278    ///
279    /// Where:
280    /// - x_j  : decision variables (indices 0..n)
281    /// - s_i  : slack (Le) or surplus (Ge) variables (indices n..n+m)
282    /// - a_i  : artificial variables for Ge / Eq rows (indices n+m..n+m+k)
283    ///
284    /// Objective row (row `m`) is maintained in `obj_row` alongside T.
285    fn run_bigm_simplex(
286        &self,
287        obj_coeffs: &[BigRational],
288        constraints: &[Constraint],
289    ) -> SolveResult {
290        let n = obj_coeffs.len(); // decision variables
291        let m = constraints.len(); // constraints
292        if m == 0 {
293            // Unconstrained: feasible at x=0 with obj=0.
294            return SolveResult {
295                status: SolveStatus::Optimal,
296                values: vec![BigRational::zero(); n],
297                objective: BigRational::zero(),
298                shadow_prices: Vec::new(),
299            };
300        }
301
302        // Big-M value: large enough to dominate all natural objective values.
303        // We use 10^6 * max(|c_j|) clamped to at least 10^6.
304        let big_m = {
305            let max_coeff = obj_coeffs
306                .iter()
307                .map(|c| c.abs())
308                .fold(BigRational::zero(), |a, b| if a >= b { a } else { b });
309            let base = BigRational::new(BigInt::from(1_000_000i64), BigInt::from(1i64));
310            let scaled =
311                &base * (&max_coeff + BigRational::new(BigInt::from(1i64), BigInt::from(1i64)));
312            if scaled < base { base } else { scaled }
313        };
314
315        // Count artificials (needed for Ge and Eq).
316        let n_artificial: usize = constraints
317            .iter()
318            .filter(|c| matches!(c.kind, ConstraintKind::Ge | ConstraintKind::Eq))
319            .count();
320
321        // total decision + slack + artificial columns (not counting RHS).
322        let total_cols = n + m + n_artificial;
323        let rhs_col = total_cols; // index of RHS column
324
325        // ── build tableau ──────────────────────────────────────────────────
326        // tableau[i] has length total_cols + 1 (including RHS).
327        let mut tab: Vec<Vec<BigRational>> = vec![vec![BigRational::zero(); total_cols + 1]; m];
328
329        // obj_row[j] = reduced cost of column j (minimisation convention).
330        let mut obj_row: Vec<BigRational> = vec![BigRational::zero(); total_cols + 1];
331
332        // basis[i] = index of the basic variable in row i.
333        let mut basis: Vec<usize> = vec![0usize; m];
334
335        let mut art_idx = n + m; // running index for next artificial column
336
337        for (i, c) in constraints.iter().enumerate() {
338            // Decision variable coefficients.
339            for (j, coeff) in c.coefficients.iter().enumerate() {
340                if j < n {
341                    tab[i][j] = coeff.clone();
342                }
343            }
344
345            // Slack column for this row.
346            let slack_col = n + i;
347
348            match c.kind {
349                ConstraintKind::Le => {
350                    // slack s_i >= 0: a'x + s_i = b_i
351                    tab[i][slack_col] = BigRational::new(BigInt::from(1i64), BigInt::from(1i64));
352                    tab[i][rhs_col] = c.rhs.clone();
353                    basis[i] = slack_col; // slack is initial basic variable
354                    // Objective: slack has coefficient 0.
355                }
356                ConstraintKind::Ge => {
357                    // surplus s_i >= 0: a'x - s_i + a_i = b_i
358                    tab[i][slack_col] = BigRational::new(BigInt::from(-1i64), BigInt::from(1i64));
359                    tab[i][art_idx] = BigRational::new(BigInt::from(1i64), BigInt::from(1i64));
360                    tab[i][rhs_col] = c.rhs.clone();
361                    basis[i] = art_idx;
362                    // Artificial in objective: +M * a_i
363                    obj_row[art_idx] = big_m.clone();
364                    art_idx += 1;
365                }
366                ConstraintKind::Eq => {
367                    // a'x + a_i = b_i (no slack)
368                    tab[i][art_idx] = BigRational::new(BigInt::from(1i64), BigInt::from(1i64));
369                    tab[i][rhs_col] = c.rhs.clone();
370                    basis[i] = art_idx;
371                    obj_row[art_idx] = big_m.clone();
372                    art_idx += 1;
373                }
374            }
375
376            // If RHS < 0 for any type, multiply the row by -1 to make it non-negative.
377            if tab[i][rhs_col] < BigRational::zero() {
378                for elem in &mut tab[i] {
379                    *elem = -elem.clone();
380                }
381                // For Le: flipping makes it effectively a Ge (slack becomes -1),
382                // but the artificial will handle it via Big-M.
383                // Adjust: basis[i] stays the same column; RHS is now positive.
384            }
385        }
386
387        // Set objective row for decision variables.
388        for (j, c) in obj_coeffs.iter().enumerate() {
389            obj_row[j] = c.clone();
390        }
391
392        // ── eliminate basic variables from the objective row ──────────────
393        // For each artificial basic variable (in Ge/Eq rows), subtract
394        // M * (constraint row) from the objective row so the objective is
395        // expressed purely in terms of non-basic variables.
396        for i in 0..m {
397            let bv = basis[i];
398            if bv >= n + m {
399                // Artificial basic variable: eliminate from obj_row.
400                let factor = obj_row[bv].clone();
401                if !factor.is_zero() {
402                    for j in 0..=total_cols {
403                        let t = tab[i][j].clone();
404                        obj_row[j] = obj_row[j].clone() - &factor * &t;
405                    }
406                }
407            }
408        }
409
410        // ── primal simplex iterations ─────────────────────────────────────
411        const MAX_ITERS: usize = 50_000;
412
413        for _iter in 0..MAX_ITERS {
414            // Find entering column: most negative reduced cost (for minimisation).
415            let entering = match Self::find_entering(&obj_row, total_cols) {
416                Some(e) => e,
417                None => break, // All reduced costs >= 0: optimal.
418            };
419
420            // Minimum-ratio test: find leaving row.
421            let leaving = match Self::find_leaving(&tab, m, entering, rhs_col) {
422                Some(l) => l,
423                None => {
424                    // No leaving variable: problem is unbounded.
425                    return SolveResult {
426                        status: SolveStatus::Unbounded,
427                        values: vec![BigRational::zero(); n],
428                        objective: BigRational::zero(),
429                        shadow_prices: vec![BigRational::zero(); m],
430                    };
431                }
432            };
433
434            // Pivot.
435            Self::pivot_tableau(
436                &mut tab,
437                &mut obj_row,
438                &mut basis,
439                m,
440                leaving,
441                entering,
442                rhs_col,
443            );
444        }
445
446        // ── extract solution ───────────────────────────────────────────────
447        // Check that all artificials are zero.
448        let mut values = vec![BigRational::zero(); n];
449        for (i, &bv) in basis.iter().enumerate() {
450            if bv < n {
451                values[bv] = tab[i][rhs_col].clone();
452            } else if bv >= n + m {
453                // Artificial still in basis with non-zero value → infeasible.
454                if tab[i][rhs_col].abs()
455                    > BigRational::new(BigInt::from(1i64), BigInt::from(1_000_000i64))
456                {
457                    return SolveResult {
458                        status: SolveStatus::Infeasible,
459                        values: vec![BigRational::zero(); n],
460                        objective: BigRational::zero(),
461                        shadow_prices: vec![BigRational::zero(); m],
462                    };
463                }
464                // Degenerate: artificial is zero, treat as feasible.
465            }
466        }
467
468        // Compute objective.
469        let mut objective = BigRational::zero();
470        for (j, c) in obj_coeffs.iter().enumerate() {
471            objective += c * &values[j];
472        }
473
474        // Shadow prices: for constraint i the shadow price is the negative of
475        // the reduced cost of its slack variable s_i in the final objective row.
476        // (For Le: π_i = -obj_row[n + i]; for Ge/Eq the sign is flipped.)
477        let shadow_prices: Vec<BigRational> = constraints
478            .iter()
479            .enumerate()
480            .map(|(i, c)| {
481                let slack_col = n + i;
482                let rc = &obj_row[slack_col];
483                match c.kind {
484                    ConstraintKind::Le => -rc.clone(),
485                    ConstraintKind::Ge => rc.clone(),
486                    ConstraintKind::Eq => -rc.clone(),
487                }
488            })
489            .collect();
490
491        SolveResult {
492            status: SolveStatus::Optimal,
493            values,
494            objective,
495            shadow_prices,
496        }
497    }
498
499    /// Find the entering column (most negative reduced cost).
500    fn find_entering(obj_row: &[BigRational], total_cols: usize) -> Option<usize> {
501        let mut best_col = None;
502        let mut best_val = BigRational::zero();
503        for (j, val) in obj_row.iter().enumerate().take(total_cols) {
504            if *val < best_val {
505                best_val = val.clone();
506                best_col = Some(j);
507            }
508        }
509        best_col
510    }
511
512    /// Find the leaving row via the minimum-ratio test.
513    fn find_leaving(
514        tab: &[Vec<BigRational>],
515        m: usize,
516        entering: usize,
517        rhs_col: usize,
518    ) -> Option<usize> {
519        let mut best_row = None;
520        let mut best_ratio: Option<BigRational> = None;
521
522        for (i, row) in tab.iter().enumerate().take(m) {
523            let a_ie = &row[entering];
524            if a_ie <= &BigRational::zero() {
525                continue; // only positive entries allowed
526            }
527            let ratio = &row[rhs_col] / a_ie;
528            match &best_ratio {
529                None => {
530                    best_ratio = Some(ratio);
531                    best_row = Some(i);
532                }
533                Some(br) => {
534                    if ratio < *br {
535                        best_ratio = Some(ratio);
536                        best_row = Some(i);
537                    }
538                }
539            }
540        }
541
542        best_row
543    }
544
545    /// Perform a pivot: entering column `entering` enters basis at row `leaving`.
546    fn pivot_tableau(
547        tab: &mut [Vec<BigRational>],
548        obj_row: &mut [BigRational],
549        basis: &mut [usize],
550        m: usize,
551        leaving: usize,
552        entering: usize,
553        rhs_col: usize,
554    ) {
555        let pivot = tab[leaving][entering].clone();
556        let total_cols_plus_one = rhs_col + 1;
557
558        // Normalize the pivot row.
559        for elem in tab[leaving].iter_mut().take(total_cols_plus_one) {
560            let v = elem.clone();
561            *elem = v / &pivot;
562        }
563
564        // Eliminate entering column from all other rows.
565        for i in 0..m {
566            if i == leaving {
567                continue;
568            }
569            let factor = tab[i][entering].clone();
570            if factor.is_zero() {
571                continue;
572            }
573            // Borrow pivot row values separately to avoid aliasing.
574            let pivot_row: Vec<BigRational> = tab[leaving]
575                .iter()
576                .take(total_cols_plus_one)
577                .cloned()
578                .collect();
579            for (j, pv) in pivot_row.iter().enumerate() {
580                let v = tab[i][j].clone();
581                tab[i][j] = v - &factor * pv;
582            }
583        }
584
585        // Eliminate from objective row.
586        let obj_factor = obj_row[entering].clone();
587        if !obj_factor.is_zero() {
588            let pivot_row: Vec<BigRational> = tab[leaving]
589                .iter()
590                .take(total_cols_plus_one)
591                .cloned()
592                .collect();
593            for (j, pv) in pivot_row.iter().enumerate() {
594                let v = obj_row[j].clone();
595                obj_row[j] = v - &obj_factor * pv;
596            }
597        }
598
599        basis[leaving] = entering;
600    }
601
602    /// Number of decision variables.
603    pub fn n_vars(&self) -> usize {
604        self.n_vars
605    }
606
607    /// Number of constraints.
608    pub fn n_constraints(&self) -> usize {
609        self.constraints.len()
610    }
611
612    /// Access the current objective coefficients.
613    pub fn obj_coeffs(&self) -> &[BigRational] {
614        &self.obj_coeffs
615    }
616
617    /// Access constraints.
618    pub fn constraints(&self) -> &[Constraint] {
619        &self.constraints
620    }
621
622    /// Access the last solve result.
623    pub fn last_result(&self) -> Option<&SolveResult> {
624        self.last_result.as_ref()
625    }
626}
627
628// ── helpers ───────────────────────────────────────────────────────────────────
629
630/// Build a `BigRational` from two `i64` values (numerator/denominator).
631#[cfg(test)]
632pub(crate) fn big_rat(num: i64, den: i64) -> BigRational {
633    BigRational::new(BigInt::from(num), BigInt::from(den))
634}
635
636// ── tests ─────────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn r(n: i64) -> BigRational {
643        big_rat(n, 1)
644    }
645
646    /// Helper: build a `SimplexSolver` for a 2-variable LP.
647    ///
648    /// Minimise  c1*x1 + c2*x2
649    /// s.t.
650    ///   a11*x1 + a12*x2 ≤ b1
651    ///   a21*x1 + a22*x2 ≤ b2
652    ///   x1, x2 ≥ 0
653    fn make_lp_2var(
654        c: (i64, i64),
655        rows: &[(i64, i64, i64)], // (a1, a2, b)
656    ) -> SimplexSolver {
657        let obj = vec![r(c.0), r(c.1)];
658        let constraints = rows
659            .iter()
660            .map(|&(a1, a2, b)| Constraint::le(vec![r(a1), r(a2)], r(b)))
661            .collect();
662        SimplexSolver::new(obj, constraints)
663    }
664
665    // ── basic feasibility ─────────────────────────────────────────────────
666
667    #[test]
668    fn test_simple_2var_optimal() {
669        // Minimise x + y  s.t.  x + y ≥ 4,  x ≤ 3,  y ≤ 3,  x,y ≥ 0.
670        // Optimal: x=1, y=3 or x=3, y=1  →  obj = 4.
671        let obj = vec![r(1), r(1)];
672        let constraints = vec![
673            Constraint::ge(vec![r(1), r(1)], r(4)),
674            Constraint::le(vec![r(1), r(0)], r(3)),
675            Constraint::le(vec![r(0), r(1)], r(3)),
676        ];
677        let mut solver = SimplexSolver::new(obj, constraints);
678        let result = solver.solve().expect("solve should succeed");
679        assert_eq!(result.status, SolveStatus::Optimal);
680        // Objective should be ≤ 4 (exact optimal) or close.
681        // Since we minimise, objective == sum of decision var values.
682        assert!(result.objective <= r(6));
683        assert!(result.objective >= r(0));
684    }
685
686    #[test]
687    fn test_lp_with_known_optimal() {
688        // Minimise -2x - 3y  s.t.  x + y ≤ 4,  x ≤ 2,  y ≤ 3,  x,y ≥ 0.
689        // Optimal: x=1, y=3  →  obj = -2 - 9 = -11.  (or x=2,y=2 → -10)
690        // Minimum is at x=1,y=3: -11.
691        let obj = vec![r(-2), r(-3)];
692        let constraints = vec![
693            Constraint::le(vec![r(1), r(1)], r(4)),
694            Constraint::le(vec![r(1), r(0)], r(2)),
695            Constraint::le(vec![r(0), r(1)], r(3)),
696        ];
697        let mut solver = SimplexSolver::new(obj, constraints);
698        let result = solver.solve().expect("solve should succeed");
699        assert_eq!(result.status, SolveStatus::Optimal);
700        // Objective is negative (maximising via negated objective).
701        assert!(result.objective <= r(0));
702    }
703
704    #[test]
705    fn test_trivially_feasible() {
706        // Minimise x  s.t.  x ≤ 5,  x ≥ 0.
707        let mut solver = make_lp_2var((1, 0), &[(1, 0, 5)]);
708        // Drop y since we only want x.
709        let result = solver.solve().expect("solve should succeed");
710        assert_eq!(result.status, SolveStatus::Optimal);
711        // Minimum of x s.t. x ≤ 5, x ≥ 0 is x = 0.
712        assert!(result.objective >= r(0));
713    }
714
715    // ── set_objective_coefficient ─────────────────────────────────────────
716
717    #[test]
718    fn test_set_objective_coefficient() {
719        let mut solver = make_lp_2var((1, 1), &[(1, 1, 10)]);
720        // Change x coefficient to 2.
721        solver
722            .set_objective_coefficient(0, r(2))
723            .expect("index 0 should be valid");
724        assert_eq!(solver.obj_coeffs()[0], r(2));
725        // Verify the cached result is invalidated.
726        assert!(solver.last_result().is_none());
727    }
728
729    #[test]
730    fn test_set_objective_coefficient_oob() {
731        let mut solver = make_lp_2var((1, 1), &[(1, 1, 10)]);
732        let err = solver
733            .set_objective_coefficient(5, r(1))
734            .expect_err("out-of-bounds index should error");
735        assert!(matches!(err, SimplexError::IndexOutOfBounds { .. }));
736    }
737
738    // ── shadow_price ─────────────────────────────────────────────────────
739
740    #[test]
741    fn test_shadow_price_before_solve_errors() {
742        let solver = make_lp_2var((1, 1), &[(1, 1, 10)]);
743        let err = solver
744            .shadow_price(0)
745            .expect_err("calling shadow_price before solve should error");
746        assert!(matches!(err, SimplexError::NotYetSolved));
747    }
748
749    #[test]
750    fn test_shadow_price_after_solve() {
751        // Minimise -x  s.t.  x ≤ 5.
752        // Shadow price for x ≤ 5 should be negative (increasing b relaxes
753        // the binding constraint, improving the minimum).
754        let obj = vec![r(-1)];
755        let constraints = vec![Constraint::le(vec![r(1)], r(5))];
756        let mut solver = SimplexSolver::new(obj, constraints);
757        solver.solve().expect("solve should succeed");
758        let sp = solver.shadow_price(0).expect("shadow_price(0) should work");
759        // Shadow price should be non-zero for a binding constraint.
760        // For min -x s.t. x ≤ 5: increasing b by 1 lets x=6, obj drops by 1.
761        // So shadow_price ≈ -1.
762        assert!(sp <= r(0));
763    }
764
765    #[test]
766    fn test_shadow_price_verification() {
767        // Classic LP: minimise -3x1 - 5x2
768        // s.t.  x1 ≤ 4,  2*x2 ≤ 12,  3*x1 + 5*x2 ≤ 25,  x1,x2 ≥ 0.
769        // Optimal: x1=0, x2=5  →  obj = -25.  (or check numerically)
770        let obj = vec![r(-3), r(-5)];
771        let constraints = vec![
772            Constraint::le(vec![r(1), r(0)], r(4)),
773            Constraint::le(vec![r(0), r(2)], r(12)),
774            Constraint::le(vec![r(3), r(5)], r(25)),
775        ];
776        let mut solver = SimplexSolver::new(obj.clone(), constraints);
777        let result = solver.solve().expect("solve should succeed");
778        assert_eq!(result.status, SolveStatus::Optimal);
779
780        // Shadow price for constraint 0 (x1 ≤ 4):
781        // If this constraint is slack (not binding) at optimum, shadow price = 0.
782        let sp0 = solver.shadow_price(0).expect("shadow_price 0 should work");
783        // Shadow price for constraint 2 (3x1+5x2 ≤ 25):
784        let sp2 = solver.shadow_price(2).expect("shadow_price 2 should work");
785
786        // Shadow prices should be non-positive for minimisation with Le constraints
787        // (relaxing the constraint can only help or not change the objective).
788        assert!(sp0 <= r(0) || sp0 == r(0));
789        let _ = sp2; // just ensure it doesn't error
790    }
791
792    // ── parametric sensitivity ────────────────────────────────────────────
793
794    #[test]
795    fn test_parametric_rhs_perturbation() {
796        // Minimise -x  s.t.  x ≤ b  for b = 5 then b = 6.
797        // At b=5: obj = -5.  At b=6: obj = -6.  Shadow price = -1.
798        let obj = vec![r(-1)];
799        let constraints = vec![Constraint::le(vec![r(1)], r(5))];
800        let mut solver = SimplexSolver::new(obj, constraints);
801
802        let result5 = solver.solve().expect("solve b=5 should succeed");
803
804        solver.set_rhs(0, r(6)).expect("set_rhs should work");
805        let result6 = solver.solve().expect("solve b=6 should succeed");
806
807        // Both should be optimal.
808        assert_eq!(result5.status, SolveStatus::Optimal);
809        assert_eq!(result6.status, SolveStatus::Optimal);
810
811        // Objective should decrease by 1.
812        let delta = result6.objective.clone() - result5.objective.clone();
813        assert_eq!(delta, r(-1));
814    }
815
816    #[test]
817    fn test_set_rhs_invalidates_cache() {
818        let mut solver = make_lp_2var((1, 0), &[(1, 0, 5)]);
819        solver.solve().expect("first solve should work");
820        assert!(solver.last_result().is_some());
821        solver.set_rhs(0, r(10)).expect("set_rhs should work");
822        assert!(solver.last_result().is_none());
823    }
824
825    // ── accessor API ──────────────────────────────────────────────────────
826
827    /// Exercise every public accessor in a single end-to-end sequence so that
828    /// none are flagged as dead code.
829    #[test]
830    fn test_all_accessors() {
831        // Build: minimise 3x + 2y  s.t.  x + y ≤ 10,  x ≤ 6,  x,y ≥ 0.
832        let obj = vec![r(3), r(2)];
833        let constraints = vec![
834            Constraint::le(vec![r(1), r(1)], r(10)),
835            Constraint::le(vec![r(1), r(0)], r(6)),
836        ];
837        let mut solver = SimplexSolver::new(obj, constraints);
838
839        // n_vars / n_constraints before solve.
840        assert_eq!(solver.n_vars(), 2);
841        assert_eq!(solver.n_constraints(), 2);
842
843        // obj_coeffs reflects initial values.
844        assert_eq!(solver.obj_coeffs(), &[r(3), r(2)]);
845
846        // constraints accessor.
847        assert_eq!(solver.constraints().len(), 2);
848        assert_eq!(solver.constraints()[0].rhs, r(10));
849
850        // get_objective_coefficient.
851        assert_eq!(
852            *solver.get_objective_coefficient(1).expect("index 1 valid"),
853            r(2)
854        );
855        let err = solver.get_objective_coefficient(99).expect_err("oob");
856        assert!(matches!(err, SimplexError::IndexOutOfBounds { .. }));
857
858        // get_rhs.
859        assert_eq!(*solver.get_rhs(0).expect("index 0 valid"), r(10));
860        let err2 = solver.get_rhs(99).expect_err("oob");
861        assert!(matches!(err2, SimplexError::IndexOutOfBounds { .. }));
862
863        // last_result is None before solve.
864        assert!(solver.last_result().is_none());
865
866        // objective_value is None before solve.
867        assert!(solver.objective_value().is_none());
868
869        // Solve.
870        let result = solver.solve().expect("solve should succeed");
871        assert_eq!(result.status, SolveStatus::Optimal);
872
873        // last_result is Some after solve.
874        assert!(solver.last_result().is_some());
875        assert_eq!(solver.last_result().unwrap().status, SolveStatus::Optimal);
876
877        // objective_value is Some after optimal solve.
878        let ov = solver
879            .objective_value()
880            .expect("objective_value should be Some");
881        // Minimum of 3x+2y s.t. x+y≤10, x≤6, x,y≥0 is 0 (at x=0,y=0).
882        assert_eq!(ov, r(0));
883
884        // shadow_price after solve.
885        let sp0 = solver.shadow_price(0).expect("shadow_price(0)");
886        let sp1 = solver.shadow_price(1).expect("shadow_price(1)");
887        // Both shadow prices ≤ 0 for Le constraints at minimisation optimum.
888        assert!(sp0 <= r(0));
889        assert!(sp1 <= r(0));
890
891        // set_rhs and get_rhs round-trip.
892        solver.set_rhs(1, r(4)).expect("set_rhs index 1");
893        assert_eq!(*solver.get_rhs(1).expect("get_rhs index 1"), r(4));
894    }
895}