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