Skip to main content

simplex_trace/
solver.rs

1use crate::error::Error;
2use crate::num::{Coef, Rational};
3use crate::problem::{Expansion, Problem, Relation, Sense, Sign};
4use crate::sensitivity::{self, Cells, Sensitivity, StandardForm};
5use crate::trace::{
6    DualRatioTest, NamedValue, Phase, RatioTest, Snapshot, Step, Termination, Trace,
7};
8
9/// Which variant of the simplex method to run.
10///
11/// More methods (such as the dual simplex) may be added in minor releases.
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Method {
17    /// Primal simplex. Only for problems whose constraints are all `<=` with a non-negative
18    /// right-hand side after normalization (the slacks give the starting basis).
19    Simplex,
20    /// Big-M method, with `M` kept symbolic (no numeric value is ever chosen).
21    BigM,
22    /// Two-phase method. Falls back to a single phase when no artificial variable is needed.
23    TwoPhase,
24    /// Dual simplex. Every constraint is written as `<=` (a `>=` is multiplied by `-1`), so
25    /// the slacks give a starting basis that may be infeasible (negative right-hand sides)
26    /// but must be dual feasible: non-negative costs when minimizing, non-positive when
27    /// maximizing, and no equality constraints. Otherwise
28    /// [`Error::NotDualFeasible`](crate::Error::NotDualFeasible) is returned.
29    DualSimplex,
30}
31
32/// Rule for choosing the entering column.
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum PivotRule {
38    /// The textbook rule: the most negative `z_j - c_j` when maximizing, the most positive
39    /// when minimizing. Ties go to the lowest column index. In the dual simplex, the row with
40    /// the most negative right-hand side leaves.
41    Dantzig,
42    /// The lowest-index column that improves the objective. Slower, but never cycles. In the
43    /// dual simplex, the negative row whose basic variable has the lowest index leaves.
44    Bland,
45}
46
47/// Solver settings.
48///
49/// ```
50/// use simplex_trace::{Options, PivotRule};
51///
52/// let options = Options::default().pivot_rule(PivotRule::Bland).max_iterations(500);
53/// assert_eq!(options.pivot_rule, PivotRule::Bland);
54/// ```
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[cfg_attr(feature = "serde", serde(default))]
57#[derive(Clone, Debug, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct Options {
60    pub pivot_rule: PivotRule,
61    /// Maximum number of pivots per phase before giving up with [`Error::IterationLimit`].
62    pub max_iterations: usize,
63}
64
65impl Default for Options {
66    fn default() -> Self {
67        Options {
68            pivot_rule: PivotRule::Dantzig,
69            max_iterations: 10_000,
70        }
71    }
72}
73
74impl Options {
75    pub fn pivot_rule(mut self, rule: PivotRule) -> Self {
76        self.pivot_rule = rule;
77        self
78    }
79
80    pub fn max_iterations(mut self, n: usize) -> Self {
81        self.max_iterations = n;
82        self
83    }
84}
85
86/// Final result of a solve.
87///
88/// With the `serde` feature: `{"status": "optimal", "objective": "17/5", "values": ["2/5", "9/5"]}`,
89/// `{"status": "infeasible"}` or `{"status": "unbounded"}`.
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum Status {
94    /// `values` holds one entry per original variable; `objective` is in the problem's own sense.
95    Optimal {
96        objective: Rational,
97        values: Vec<Rational>,
98    },
99    Infeasible,
100    Unbounded,
101}
102
103/// The status, the problem that was solved, the sensitivity analysis (when optimal) and the
104/// full step-by-step trace.
105///
106/// With the `serde` feature the status fields sit at the top level:
107/// `{"status": "optimal", "objective": "17/5", "values": [...], "problem": {...},
108/// "sensitivity": {...}, "trace": {...}}`.
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110#[derive(Clone, Debug, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct Solution {
113    #[cfg_attr(feature = "serde", serde(flatten))]
114    pub status: Status,
115    /// The problem this solution answers, so that [`reoptimize`] can extend it.
116    pub problem: Problem,
117    /// Shadow prices, reduced costs and ranges, computed for every optimal solution.
118    ///
119    /// `None` exactly when the status is not optimal.
120    #[cfg_attr(
121        feature = "serde",
122        serde(default, skip_serializing_if = "Option::is_none")
123    )]
124    pub sensitivity: Option<Sensitivity>,
125    pub trace: Trace,
126}
127
128/// Solves `problem` with the given method, recording every step.
129///
130/// ```
131/// use simplex_trace::{solve, Method, Options, Problem, Relation, Status, ratio};
132///
133/// let problem = Problem::maximize([3, 5])
134///     .subject_to([1, 0], Relation::Le, 4)
135///     .subject_to([0, 2], Relation::Le, 12)
136///     .subject_to([3, 2], Relation::Le, 18);
137///
138/// let solution = solve(&problem, Method::Simplex, &Options::default()).unwrap();
139/// assert_eq!(
140///     solution.status,
141///     Status::Optimal { objective: ratio(36, 1), values: vec![ratio(2, 1), ratio(6, 1)] }
142/// );
143/// println!("{}", solution.trace);
144/// ```
145pub fn solve(problem: &Problem, method: Method, options: &Options) -> Result<Solution, Error> {
146    problem.validate()?;
147    let mut trace = Trace::new(problem.sense);
148    // Every method works on non-negative columns; free and non-positive variables are
149    // substituted here and combined back in `finish`.
150    let expansion = problem.expand();
151    let columns = &expansion.problem;
152    let sense = problem.sense;
153    let costs = &columns.objective;
154
155    if method == Method::DualSimplex {
156        check_dual_feasible(problem)?;
157        let mut t = Tableau::dual_form(columns);
158        t.set_objective(sense, "z", costs, &Coef::zero());
159        let end = t.optimize_dual(options, &mut trace)?;
160        return Ok(finish(&t, problem, &expansion, end, trace));
161    }
162
163    let mut t = Tableau::standard_form(columns);
164    let end = match method {
165        Method::Simplex if t.has_artificials() => return Err(Error::ArtificialVariablesRequired),
166        Method::Simplex => t.single_phase(sense, costs, Coef::zero(), options, &mut trace)?,
167        Method::TwoPhase if !t.has_artificials() => {
168            t.single_phase(sense, costs, Coef::zero(), options, &mut trace)?
169        }
170        Method::TwoPhase => t.two_phase(sense, costs, options, &mut trace)?,
171        Method::BigM => {
172            // The penalty always works against the objective: -M when maximizing, +M when minimizing.
173            let penalty = match sense {
174                Sense::Maximize => Coef::big_m(-Rational::one()),
175                Sense::Minimize => Coef::big_m(Rational::one()),
176            };
177            t.single_phase(sense, costs, penalty, options, &mut trace)?
178        }
179        Method::DualSimplex => unreachable!("handled above"),
180    };
181    Ok(finish(&t, problem, &expansion, end, trace))
182}
183
184/// Re-optimizes a solved problem after appending constraints to it, with the dual simplex.
185///
186/// `problem` must be `previous.problem` plus zero or more `<=` / `>=` constraints at the end,
187/// and `previous` must be optimal. The trace starts from the previous final tableau (without
188/// artificial columns) with the new rows added, each expressed in the current basis with its
189/// own slack basic ([`Step::ConstraintAdded`]), and continues with dual simplex pivots until
190/// the tableau is feasible again or a row proves the extended problem infeasible.
191///
192/// ```
193/// use simplex_trace::{reoptimize, solve, Method, Options, Problem, Relation, Status, ratio};
194///
195/// // TOYCO (Taha): max 3x1 + 2x2 + 5x3, optimal at z = 1350.
196/// let toyco = Problem::maximize([3, 2, 5])
197///     .subject_to([1, 2, 1], Relation::Le, 430)
198///     .subject_to([3, 0, 2], Relation::Le, 460)
199///     .subject_to([1, 4, 0], Relation::Le, 420);
200/// let first = solve(&toyco, Method::Simplex, &Options::default()).unwrap();
201///
202/// // A new constraint the current optimum violates: one dual pivot fixes it.
203/// let extended = toyco.subject_to([3, 3, 1], Relation::Le, 500);
204/// let second = reoptimize(&first, &extended, &Options::default()).unwrap();
205/// assert_eq!(
206///     second.status,
207///     Status::Optimal { objective: ratio(1330, 1), values: vec![ratio(0, 1), ratio(90, 1), ratio(230, 1)] }
208/// );
209/// assert_eq!(second.trace.pivot_count(), 1);
210/// ```
211pub fn reoptimize(
212    previous: &Solution,
213    problem: &Problem,
214    options: &Options,
215) -> Result<Solution, Error> {
216    problem.validate()?;
217    let old = &previous.problem;
218    let (Status::Optimal { .. }, Some(sensitivity)) = (&previous.status, &previous.sensitivity)
219    else {
220        return Err(Error::NotOptimal);
221    };
222    let m = old.constraints.len();
223    let same_prefix = problem.sense == old.sense
224        && problem.objective == old.objective
225        && problem.var_names == old.var_names
226        && (0..problem.num_vars()).all(|j| problem.sign(j) == old.sign(j))
227        && problem.constraints.len() >= m
228        && problem.constraints[..m] == old.constraints[..];
229    if !same_prefix {
230        return Err(Error::InvalidProblem(
231            "the extended problem must be the previous problem with constraints appended".into(),
232        ));
233    }
234    for (i, c) in problem.constraints.iter().enumerate().skip(m) {
235        if c.relation == Relation::Eq {
236            return Err(Error::NotDualFeasible(format!(
237                "constraint {} is an equality; added constraints must be <= or >=",
238                i + 1
239            )));
240        }
241    }
242
243    let mut trace = Trace::new(problem.sense);
244    let expansion = problem.expand();
245    let mut t = Tableau::from_basis(&old.expand().problem, sensitivity)?;
246    t.append_constraints(&expansion.problem, m, &mut trace);
247    let end = t.optimize_dual(options, &mut trace)?;
248    Ok(finish(&t, problem, &expansion, end, trace))
249}
250
251/// The dual simplex needs the slack basis to be dual feasible: the starting objective row is
252/// `-c_j` for every column, which has the optimal sign exactly when every column cost has the
253/// right sign. A non-positive variable's column costs `-c_j`; a free variable has columns
254/// costing `c_j` and `-c_j`, so its cost must be zero.
255fn check_dual_feasible(problem: &Problem) -> Result<(), Error> {
256    for (i, c) in problem.constraints.iter().enumerate() {
257        if c.relation == Relation::Eq {
258            return Err(Error::NotDualFeasible(format!(
259                "constraint {} is an equality, which has no slack",
260                i + 1
261            )));
262        }
263    }
264    let (wrong, needs) = match problem.sense {
265        Sense::Maximize => (
266            Rational::is_positive as fn(&Rational) -> bool,
267            "non-positive",
268        ),
269        Sense::Minimize => (
270            Rational::is_negative as fn(&Rational) -> bool,
271            "non-negative",
272        ),
273    };
274    let when = match problem.sense {
275        Sense::Maximize => "maximizing",
276        Sense::Minimize => "minimizing",
277    };
278    for (j, c) in problem.objective.iter().enumerate() {
279        let name = problem.var_name(j);
280        match problem.sign(j) {
281            Sign::Free if !c.is_zero() => {
282                return Err(Error::NotDualFeasible(format!(
283                    "{name} is free and has cost {c}; its columns {name}+ and {name}- have opposite costs, so one of them always has the wrong sign unless the cost is 0"
284                )));
285            }
286            Sign::NonPositive if wrong(&-c) => {
287                return Err(Error::NotDualFeasible(format!(
288                    "{name} is non-positive and has cost {c}, so its column {name}- costs {}, but column costs must be {needs} when {when}",
289                    -c
290                )));
291            }
292            Sign::NonNegative if wrong(c) => {
293                return Err(Error::NotDualFeasible(format!(
294                    "{name} has cost {c}, but the costs must be {needs} when {when}"
295                )));
296            }
297            _ => {}
298        }
299    }
300    Ok(())
301}
302
303/// Builds the solution from the final tableau: status, sensitivity (if optimal) and the
304/// closing step of the trace, all in terms of the original variables.
305fn finish(
306    t: &Tableau,
307    problem: &Problem,
308    expansion: &Expansion,
309    end: End,
310    mut trace: Trace,
311) -> Solution {
312    let sensitivity = match end {
313        End::Optimal => t.sensitivity(problem, expansion),
314        _ => None,
315    };
316    let (status, termination) = match end {
317        End::Optimal => {
318            let columns = t.values(expansion.problem.num_vars());
319            let status = Status::Optimal {
320                objective: t.z_rhs.c.clone(),
321                values: expansion.combine(&columns),
322            };
323            (status, Termination::Optimal)
324        }
325        End::Infeasible => (Status::Infeasible, Termination::Infeasible),
326        End::Unbounded => (Status::Unbounded, Termination::Unbounded),
327    };
328    trace.push(Step::Finished { termination });
329    Solution {
330        status,
331        problem: problem.clone(),
332        sensitivity,
333        trace,
334    }
335}
336
337enum End {
338    Optimal,
339    Infeasible,
340    Unbounded,
341}
342
343#[derive(Clone)]
344struct Tableau {
345    /// Sense of the objective currently in the `z` row (phase 1 is always `Minimize`).
346    sense: Sense,
347    objective_label: &'static str,
348    names: Vec<String>,
349    artificial: Vec<bool>,
350    /// For each artificial column, the constraint it was added to.
351    artificial_owner: Vec<Option<usize>>,
352    /// Redundant equalities whose row was removed (by constraint index).
353    removed: Vec<usize>,
354    rows: Vec<Vec<Rational>>,
355    rhs: Vec<Rational>,
356    basis: Vec<usize>,
357    z: Vec<Coef>,
358    z_rhs: Coef,
359}
360
361impl Tableau {
362    /// Normalizes every row to a non-negative right-hand side and adds one slack (`<=`),
363    /// surplus (`>=`) and/or artificial (`>=`, `=`) column per constraint.
364    /// Column order: original variables, slack/surplus, artificial.
365    fn standard_form(problem: &Problem) -> Self {
366        let n = problem.num_vars();
367        let normalized: Vec<(Vec<Rational>, Relation, Rational)> = problem
368            .constraints
369            .iter()
370            .map(|c| {
371                if c.rhs.is_negative() {
372                    let flipped = match c.relation {
373                        Relation::Le => Relation::Ge,
374                        Relation::Ge => Relation::Le,
375                        Relation::Eq => Relation::Eq,
376                    };
377                    (
378                        c.coeffs.iter().map(|v| -v.clone()).collect(),
379                        flipped,
380                        -c.rhs.clone(),
381                    )
382                } else {
383                    (c.coeffs.clone(), c.relation, c.rhs.clone())
384                }
385            })
386            .collect();
387
388        let mut names: Vec<String> = (0..n).map(|j| problem.var_name(j)).collect();
389        let mut slack_col = vec![None; normalized.len()];
390        for (i, (_, relation, _)) in normalized.iter().enumerate() {
391            if *relation != Relation::Eq {
392                slack_col[i] = Some(names.len());
393                names.push(format!("s{}", i + 1));
394            }
395        }
396        let first_artificial = names.len();
397        let mut art_col = vec![None; normalized.len()];
398        let mut artificial_owner = vec![None; first_artificial];
399        for (i, (_, relation, _)) in normalized.iter().enumerate() {
400            if *relation != Relation::Le {
401                art_col[i] = Some(names.len());
402                artificial_owner.push(Some(i));
403                names.push(format!("a{}", i + 1));
404            }
405        }
406
407        let width = names.len();
408        let mut rows = Vec::with_capacity(normalized.len());
409        let mut rhs = Vec::with_capacity(normalized.len());
410        let mut basis = Vec::with_capacity(normalized.len());
411        for (i, (coeffs, relation, b)) in normalized.into_iter().enumerate() {
412            let mut row = vec![Rational::zero(); width];
413            row[..n].clone_from_slice(&coeffs);
414            if let Some(s) = slack_col[i] {
415                row[s] = if relation == Relation::Le {
416                    Rational::one()
417                } else {
418                    -Rational::one()
419                };
420            }
421            if let Some(a) = art_col[i] {
422                row[a] = Rational::one();
423            }
424            basis.push(
425                art_col[i]
426                    .or(slack_col[i])
427                    .expect("every row has a basic column"),
428            );
429            rows.push(row);
430            rhs.push(b);
431        }
432
433        Tableau {
434            sense: problem.sense,
435            objective_label: "z",
436            artificial: (0..width).map(|j| j >= first_artificial).collect(),
437            artificial_owner,
438            removed: Vec::new(),
439            names,
440            rows,
441            rhs,
442            basis,
443            z: vec![Coef::zero(); width],
444            z_rhs: Coef::zero(),
445        }
446    }
447
448    /// The dual simplex start: every constraint written as `<=` (a `>=` is multiplied by `-1`,
449    /// whatever the sign of its right-hand side) with a slack, so the slacks form the basis.
450    /// Right-hand sides may be negative. Equalities must have been rejected before.
451    fn dual_form(problem: &Problem) -> Self {
452        let n = problem.num_vars();
453        let m = problem.constraints.len();
454        let width = n + m;
455        let mut names: Vec<String> = (0..n).map(|j| problem.var_name(j)).collect();
456        names.extend((0..m).map(|i| format!("s{}", i + 1)));
457
458        let mut rows = Vec::with_capacity(m);
459        let mut rhs = Vec::with_capacity(m);
460        for (i, c) in problem.constraints.iter().enumerate() {
461            let mut row = vec![Rational::zero(); width];
462            let (coeffs, b) = match c.relation {
463                Relation::Le => (c.coeffs.clone(), c.rhs.clone()),
464                Relation::Ge => (
465                    c.coeffs.iter().map(|v| -v.clone()).collect(),
466                    -c.rhs.clone(),
467                ),
468                Relation::Eq => unreachable!("rejected by check_dual_feasible"),
469            };
470            row[..n].clone_from_slice(&coeffs);
471            row[n + i] = Rational::one();
472            rows.push(row);
473            rhs.push(b);
474        }
475
476        Tableau {
477            sense: problem.sense,
478            objective_label: "z",
479            artificial: vec![false; width],
480            artificial_owner: vec![None; width],
481            removed: Vec::new(),
482            names,
483            rows,
484            rhs,
485            basis: (n..width).collect(),
486            z: vec![Coef::zero(); width],
487            z_rhs: Coef::zero(),
488        }
489    }
490
491    /// Rebuilds the final tableau of an optimal solution of `problem` from its basis: the
492    /// real columns of the standard form (artificial columns dropped), without the rows of
493    /// redundant equalities, pivoted to `sensitivity.basic_variables` in that row order.
494    fn from_basis(problem: &Problem, sensitivity: &Sensitivity) -> Result<Self, Error> {
495        let mismatch =
496            || Error::InvalidProblem("the previous solution does not belong to its problem".into());
497        let mut t = Tableau::standard_form(problem);
498        let real = t
499            .artificial
500            .iter()
501            .position(|&a| a)
502            .unwrap_or(t.names.len());
503        t.names.truncate(real);
504        t.artificial.truncate(real);
505        t.artificial_owner.truncate(real);
506        for row in t.rows.iter_mut() {
507            row.truncate(real);
508        }
509        t.z = vec![Coef::zero(); real];
510
511        if sensitivity.constraints.len() != t.rows.len() {
512            return Err(mismatch());
513        }
514        t.removed = (0..t.rows.len())
515            .filter(|&i| sensitivity.constraints[i].redundant)
516            .collect();
517        for &i in t.removed.iter().rev() {
518            t.rows.remove(i);
519            t.rhs.remove(i);
520        }
521
522        let basis: Vec<usize> = sensitivity
523            .basic_variables
524            .iter()
525            .map(|name| t.names.iter().position(|n| n == name).ok_or_else(mismatch))
526            .collect::<Result<_, _>>()?;
527        if basis.len() != t.rows.len() {
528            return Err(mismatch());
529        }
530        // Gauss-Jordan: make each basic column a unit vector, one unassigned row at a time
531        // (the basis matrix is nonsingular, so a usable row always exists), then put the
532        // rows in basis order so the tableau reads like the one in the trace.
533        let mut assigned = vec![false; t.rows.len()];
534        let mut row_of = Vec::with_capacity(basis.len());
535        for &j in &basis {
536            let i = (0..t.rows.len())
537                .find(|&i| !assigned[i] && !t.rows[i][j].is_zero())
538                .ok_or_else(mismatch)?;
539            t.pivot(i, j);
540            assigned[i] = true;
541            row_of.push(i);
542        }
543        t.rows = row_of.iter().map(|&i| t.rows[i].clone()).collect();
544        t.rhs = row_of.iter().map(|&i| t.rhs[i].clone()).collect();
545        t.basis = basis;
546        t.set_objective(problem.sense, "z", &problem.objective, &Coef::zero());
547        Ok(t)
548    }
549
550    /// Appends constraints `from..` of `problem` as new rows, each written as `<=` with a new
551    /// slack that is basic, and priced out against the current basis so the tableau stays
552    /// consistent. A negative right-hand side means the current solution violates it.
553    fn append_constraints(&mut self, problem: &Problem, from: usize, trace: &mut Trace) {
554        let n = problem.num_vars();
555        for (i, c) in problem.constraints.iter().enumerate().skip(from) {
556            let (coeffs, mut b): (Vec<Rational>, Rational) = match c.relation {
557                Relation::Le => (c.coeffs.clone(), c.rhs.clone()),
558                Relation::Ge => (
559                    c.coeffs.iter().map(|v| -v.clone()).collect(),
560                    -c.rhs.clone(),
561                ),
562                Relation::Eq => unreachable!("rejected by reoptimize"),
563            };
564            self.names.push(format!("s{}", i + 1));
565            self.artificial.push(false);
566            self.artificial_owner.push(None);
567            self.z.push(Coef::zero());
568            for row in self.rows.iter_mut() {
569                row.push(Rational::zero());
570            }
571            let width = self.names.len();
572            let mut row = vec![Rational::zero(); width];
573            row[..n].clone_from_slice(&coeffs);
574            row[width - 1] = Rational::one();
575            for (r, &j) in self.basis.iter().enumerate() {
576                let factor = row[j].clone();
577                if factor.is_zero() {
578                    continue;
579                }
580                for (v, p) in row.iter_mut().zip(&self.rows[r]) {
581                    *v -= &factor * p;
582                }
583                b -= &factor * &self.rhs[r];
584            }
585            let satisfied = !b.is_negative();
586            self.rows.push(row);
587            self.rhs.push(b);
588            self.basis.push(width - 1);
589            trace.push(Step::ConstraintAdded {
590                constraint: i,
591                row: self.rows.len() - 1,
592                satisfied,
593            });
594        }
595    }
596
597    fn has_artificials(&self) -> bool {
598        self.artificial.iter().any(|&a| a)
599    }
600
601    fn snapshot(&self) -> Snapshot {
602        Snapshot {
603            sense: self.sense,
604            objective_label: self.objective_label.to_string(),
605            columns: self.names.clone(),
606            basis: self.basis.clone(),
607            rows: self.rows.clone(),
608            rhs: self.rhs.clone(),
609            z: self.z.clone(),
610            z_rhs: self.z_rhs.clone(),
611        }
612    }
613
614    /// Sets the objective row to `z_j - c_j` for the objective `sense` with costs `costs`
615    /// (original variables; slack/surplus cost 0, artificials cost `artificial_cost`), then
616    /// prices out the basic columns so the row is consistent with the current basis.
617    fn set_objective(
618        &mut self,
619        sense: Sense,
620        label: &'static str,
621        costs: &[Rational],
622        artificial_cost: &Coef,
623    ) {
624        self.sense = sense;
625        self.objective_label = label;
626        self.z = (0..self.names.len())
627            .map(|j| {
628                if j < costs.len() {
629                    -Coef::constant(costs[j].clone())
630                } else if self.artificial[j] {
631                    -artificial_cost.clone()
632                } else {
633                    Coef::zero()
634                }
635            })
636            .collect();
637        self.z_rhs = Coef::zero();
638        for i in 0..self.rows.len() {
639            let factor = self.z[self.basis[i]].clone();
640            if factor.is_zero() {
641                continue;
642            }
643            for (zj, aij) in self.z.iter_mut().zip(&self.rows[i]) {
644                zj.sub_mul(&factor, aij);
645            }
646            self.z_rhs.sub_mul(&factor, &self.rhs[i]);
647        }
648    }
649
650    fn pivot(&mut self, row: usize, col: usize) {
651        let pivot = self.rows[row][col].clone();
652        for v in self.rows[row].iter_mut() {
653            *v /= &pivot;
654        }
655        self.rhs[row] /= &pivot;
656
657        let pivot_row = self.rows[row].clone();
658        let pivot_rhs = self.rhs[row].clone();
659        for i in 0..self.rows.len() {
660            if i == row || self.rows[i][col].is_zero() {
661                continue;
662            }
663            let factor = self.rows[i][col].clone();
664            for (v, p) in self.rows[i].iter_mut().zip(&pivot_row) {
665                *v -= &factor * p;
666            }
667            self.rhs[i] -= &factor * &pivot_rhs;
668        }
669
670        let factor = self.z[col].clone();
671        if !factor.is_zero() {
672            for (zj, p) in self.z.iter_mut().zip(&pivot_row) {
673                zj.sub_mul(&factor, p);
674            }
675            self.z_rhs.sub_mul(&factor, &pivot_rhs);
676        }
677        self.basis[row] = col;
678    }
679
680    /// Maximizing: a column improves when `z_j - c_j < 0`, Dantzig picks the most negative.
681    /// Minimizing: when `z_j - c_j > 0`, Dantzig picks the most positive.
682    ///
683    /// With Big-M the comparison is lexicographic: the `M` part (the penalty on artificials)
684    /// is improved first, the real objective only once the penalty cannot improve. Dantzig
685    /// gets this from the ordering of [`Coef`]; Bland picks the lowest-index column that
686    /// improves the `M` part, and only if none does, the lowest-index column that improves
687    /// the constant part. That is Bland applied to two problems in sequence (the `M` row does
688    /// not change while columns with no `M` part enter), so it still cannot cycle.
689    fn entering(&self, rule: PivotRule) -> Option<usize> {
690        let sense = self.sense;
691        let improves = |j: &usize| match sense {
692            Sense::Maximize => self.z[*j].is_negative(),
693            Sense::Minimize => self.z[*j].is_positive(),
694        };
695        let improves_penalty = |j: &usize| match sense {
696            Sense::Maximize => self.z[*j].m.is_negative(),
697            Sense::Minimize => self.z[*j].m.is_positive(),
698        };
699        let mut candidates = (0..self.z.len()).filter(improves);
700        match rule {
701            PivotRule::Bland => (0..self.z.len())
702                .find(improves_penalty)
703                .or_else(|| candidates.next()),
704            // `min_by` keeps the first of equal elements, so ties go to the lowest index.
705            PivotRule::Dantzig => candidates.min_by(|&a, &b| match sense {
706                Sense::Maximize => self.z[a].cmp(&self.z[b]),
707                Sense::Minimize => self.z[b].cmp(&self.z[a]),
708            }),
709        }
710    }
711
712    /// Minimum-ratio test. Ties go to the row whose basic variable has the lowest index.
713    fn ratio_test(&self, col: usize) -> (Vec<RatioTest>, Option<usize>) {
714        let ratios: Vec<RatioTest> = (0..self.rows.len())
715            .map(|i| {
716                let a = &self.rows[i][col];
717                let ratio = if a.is_positive() {
718                    Some(&self.rhs[i] / a)
719                } else {
720                    None
721                };
722                RatioTest { row: i, ratio }
723            })
724            .collect();
725        let leaving = ratios
726            .iter()
727            .filter_map(|r| r.ratio.as_ref().map(|v| (v, self.basis[r.row], r.row)))
728            .min()
729            .map(|(_, _, row)| row);
730        (ratios, leaving)
731    }
732
733    /// Runs simplex iterations from the current (feasible) basis until optimal or unbounded.
734    fn optimize(
735        &mut self,
736        phase: Phase,
737        options: &Options,
738        trace: &mut Trace,
739    ) -> Result<End, Error> {
740        let mut iteration = 0;
741        loop {
742            trace.push(Step::Tableau {
743                phase,
744                iteration,
745                snapshot: self.snapshot(),
746            });
747
748            let Some(entering) = self.entering(options.pivot_rule) else {
749                return Ok(End::Optimal);
750            };
751            let (ratios, leaving_row) = self.ratio_test(entering);
752            let Some(leaving_row) = leaving_row else {
753                return Ok(End::Unbounded);
754            };
755            if iteration == options.max_iterations {
756                return Err(Error::IterationLimit(options.max_iterations));
757            }
758
759            let leaving = self.basis[leaving_row];
760            trace.push(Step::Pivot {
761                phase,
762                iteration,
763                entering,
764                entering_name: self.names[entering].clone(),
765                leaving_row,
766                leaving,
767                leaving_name: self.names[leaving].clone(),
768                pivot_value: self.rows[leaving_row][entering].clone(),
769                ratios,
770            });
771            self.pivot(leaving_row, entering);
772            iteration += 1;
773        }
774    }
775
776    /// Dual simplex iterations from a dual feasible tableau (objective row with the optimal
777    /// sign) until every right-hand side is non-negative, or a row proves infeasibility.
778    fn optimize_dual(&mut self, options: &Options, trace: &mut Trace) -> Result<End, Error> {
779        let phase = Phase::Dual;
780        let mut iteration = 0;
781        loop {
782            trace.push(Step::Tableau {
783                phase,
784                iteration,
785                snapshot: self.snapshot(),
786            });
787
788            let Some(leaving_row) = self.leaving_row(options.pivot_rule) else {
789                return Ok(End::Optimal);
790            };
791            let leaving = self.basis[leaving_row];
792            let (ratios, entering) = self.dual_ratio_test(leaving_row);
793            let Some(entering) = entering else {
794                trace.push(Step::InfeasibleRow {
795                    row: leaving_row,
796                    basic: NamedValue::new(
797                        self.names[leaving].clone(),
798                        self.rhs[leaving_row].clone(),
799                    ),
800                });
801                return Ok(End::Infeasible);
802            };
803            if iteration == options.max_iterations {
804                return Err(Error::IterationLimit(options.max_iterations));
805            }
806
807            trace.push(Step::DualPivot {
808                phase,
809                iteration,
810                leaving_row,
811                leaving,
812                leaving_name: self.names[leaving].clone(),
813                entering,
814                entering_name: self.names[entering].clone(),
815                pivot_value: self.rows[leaving_row][entering].clone(),
816                ratios,
817            });
818            self.pivot(leaving_row, entering);
819            iteration += 1;
820        }
821    }
822
823    /// Dual simplex leaving row, among those with a negative right-hand side: Dantzig takes
824    /// the most negative (ties: lowest basic variable index), Bland the lowest basic variable
825    /// index. `None` when the tableau is already feasible.
826    fn leaving_row(&self, rule: PivotRule) -> Option<usize> {
827        let negative = (0..self.rows.len()).filter(|&i| self.rhs[i].is_negative());
828        match rule {
829            PivotRule::Dantzig => negative.min_by_key(|&i| (self.rhs[i].clone(), self.basis[i])),
830            PivotRule::Bland => negative.min_by_key(|&i| self.basis[i]),
831        }
832    }
833
834    /// Dual ratio test on `row`: among the columns with a negative entry, the one with the
835    /// smallest `|z_j - c_j| / |a_rj|` enters, which keeps the objective row dual feasible
836    /// after the pivot. Ties go to the lowest column index. No eligible column means the row
837    /// cannot be satisfied.
838    fn dual_ratio_test(&self, row: usize) -> (Vec<DualRatioTest>, Option<usize>) {
839        let ratios: Vec<DualRatioTest> = (0..self.names.len())
840            .map(|j| {
841                let a = &self.rows[row][j];
842                let ratio = a.is_negative().then(|| {
843                    debug_assert!(self.z[j].m.is_zero(), "dual simplex with a symbolic M");
844                    self.z[j].c.abs() / a.abs()
845                });
846                DualRatioTest { column: j, ratio }
847            })
848            .collect();
849        let entering = ratios
850            .iter()
851            .filter_map(|r| r.ratio.as_ref().map(|v| (v, r.column)))
852            .min()
853            .map(|(_, j)| j);
854        (ratios, entering)
855    }
856
857    /// Basic artificial variables with a positive value.
858    fn positive_artificials(&self) -> Vec<NamedValue> {
859        (0..self.rows.len())
860            .filter(|&i| self.artificial[self.basis[i]] && self.rhs[i].is_positive())
861            .map(|i| NamedValue::new(self.names[self.basis[i]].clone(), self.rhs[i].clone()))
862            .collect()
863    }
864
865    /// Plain simplex (`artificial_cost == 0`, no artificials present) or Big-M (`∓M`).
866    fn single_phase(
867        &mut self,
868        sense: Sense,
869        costs: &[Rational],
870        artificial_cost: Coef,
871        options: &Options,
872        trace: &mut Trace,
873    ) -> Result<End, Error> {
874        self.set_objective(sense, "z", costs, &artificial_cost);
875        let end = self.optimize(Phase::Single, options, trace)?;
876
877        // Big-M stops in two ways: an optimal tableau, or an unbounded direction. The entering
878        // column of an unbounded direction cannot improve the `M` part (the penalty is
879        // bounded), and columns that improve the `M` part always enter first. So in both cases
880        // the penalty can no longer improve: the sum of artificials is at its minimum. If an
881        // artificial is still positive, no feasible point exists.
882        let positive = self.positive_artificials();
883        if positive.is_empty() {
884            return Ok(end);
885        }
886        trace.push(Step::ArtificialRemainsPositive {
887            artificials: positive,
888            unbounded_direction: matches!(end, End::Unbounded),
889        });
890        Ok(End::Infeasible)
891    }
892
893    fn two_phase(
894        &mut self,
895        sense: Sense,
896        costs: &[Rational],
897        options: &Options,
898        trace: &mut Trace,
899    ) -> Result<End, Error> {
900        // Phase 1: minimize r = sum of artificials (each artificial costs 1, the rest 0).
901        let zeros = vec![Rational::zero(); costs.len()];
902        self.set_objective(
903            Sense::Minimize,
904            "r",
905            &zeros,
906            &Coef::constant(Rational::one()),
907        );
908        // r is bounded below by 0, so phase 1 always ends optimal.
909        self.optimize(Phase::One, options, trace)?;
910
911        let artificial_sum = self.z_rhs.c.clone();
912        let feasible = artificial_sum.is_zero();
913        trace.push(Step::PhaseOneFinished {
914            artificial_sum,
915            feasible,
916        });
917        if !feasible {
918            return Ok(End::Infeasible);
919        }
920
921        self.drop_artificials(trace);
922        self.set_objective(sense, "z", costs, &Coef::zero());
923        self.optimize(Phase::Two, options, trace)
924    }
925
926    /// After a feasible phase 1: swaps zero-valued artificials out of the basis (or removes
927    /// their row if it is redundant), then deletes the artificial columns.
928    fn drop_artificials(&mut self, trace: &mut Trace) {
929        let mut i = 0;
930        while i < self.rows.len() {
931            if !self.artificial[self.basis[i]] {
932                i += 1;
933                continue;
934            }
935            let replacement =
936                (0..self.names.len()).find(|&j| !self.artificial[j] && !self.rows[i][j].is_zero());
937            match replacement {
938                Some(j) => {
939                    self.pivot(i, j);
940                    trace.push(Step::ArtificialPivotedOut {
941                        row: i,
942                        entering: j,
943                        entering_name: self.names[j].clone(),
944                    });
945                    i += 1;
946                }
947                None => {
948                    // The basic artificial's own constraint is the redundant one: its row
949                    // is the only one with a 1 under that artificial's column.
950                    self.removed
951                        .push(self.artificial_owner[self.basis[i]].expect("artificial column"));
952                    self.rows.remove(i);
953                    self.rhs.remove(i);
954                    self.basis.remove(i);
955                    trace.push(Step::RedundantRowRemoved { row: i });
956                }
957            }
958        }
959
960        let keep: Vec<usize> = (0..self.names.len())
961            .filter(|&j| !self.artificial[j])
962            .collect();
963        let mut new_index = vec![usize::MAX; self.names.len()];
964        for (new, &old) in keep.iter().enumerate() {
965            new_index[old] = new;
966        }
967        self.names = keep.iter().map(|&j| self.names[j].clone()).collect();
968        self.artificial = vec![false; keep.len()];
969        self.artificial_owner = vec![None; keep.len()];
970        for row in self.rows.iter_mut() {
971            *row = keep.iter().map(|&j| row[j].clone()).collect();
972        }
973        for b in self.basis.iter_mut() {
974            *b = new_index[*b];
975        }
976        self.z = vec![Coef::zero(); keep.len()];
977        self.z_rhs = Coef::zero();
978    }
979
980    /// Sensitivity of the final (optimal) basis.
981    ///
982    /// With Big-M a basic artificial may remain at zero. If its row is zero in every real
983    /// column it is a redundant equality and is dropped, as the two-phase method does.
984    /// Otherwise the basis is not one of the real problem: on a copy, the artificials are
985    /// pivoted out and the real objective re-optimized. Those pivots are all degenerate (the
986    /// solution is already optimal, so no pivot can move it), so the analysis still describes
987    /// the reported solution.
988    fn sensitivity(&self, original: &Problem, expansion: &Expansion) -> Option<Sensitivity> {
989        let mut removed = self.removed.clone();
990        let mut basis = Vec::with_capacity(self.basis.len());
991        for (i, &b) in self.basis.iter().enumerate() {
992            if !self.artificial[b] {
993                basis.push(b);
994                continue;
995            }
996            let redundant =
997                (0..self.names.len()).all(|j| self.artificial[j] || self.rows[i][j].is_zero());
998            if !redundant {
999                return self.rebased_sensitivity(original, expansion);
1000            }
1001            removed.push(self.artificial_owner[b].expect("artificial column"));
1002        }
1003        let cells = Cells::FinalTableau {
1004            artificial_columns: self.has_artificials(),
1005        };
1006        self.analyze(original, expansion, &basis, &removed, cells)
1007    }
1008
1009    fn rebased_sensitivity(
1010        &self,
1011        original: &Problem,
1012        expansion: &Expansion,
1013    ) -> Option<Sensitivity> {
1014        let columns = &expansion.problem;
1015        let mut t = self.clone();
1016        let mut scratch = Trace::new(columns.sense);
1017        t.drop_artificials(&mut scratch);
1018        t.set_objective(columns.sense, "z", &columns.objective, &Coef::zero());
1019        let options = Options::default().pivot_rule(PivotRule::Bland);
1020        match t.optimize(Phase::Two, &options, &mut scratch) {
1021            Ok(End::Optimal) => t.analyze(original, expansion, &t.basis, &t.removed, Cells::None),
1022            _ => None, // unreachable: the starting basis is feasible and optimal in value
1023        }
1024    }
1025
1026    fn analyze(
1027        &self,
1028        original: &Problem,
1029        expansion: &Expansion,
1030        basis: &[usize],
1031        removed: &[usize],
1032        cells: Cells,
1033    ) -> Option<Sensitivity> {
1034        let start = Tableau::standard_form(&expansion.problem);
1035        let form = StandardForm {
1036            rows: &start.rows,
1037            rhs: &start.rhs,
1038            names: &start.names,
1039            artificial: &start.artificial,
1040        };
1041        sensitivity::analyze(original, expansion, form, basis, removed, cells)
1042    }
1043
1044    fn values(&self, n: usize) -> Vec<Rational> {
1045        let mut values = vec![Rational::zero(); n];
1046        for (i, &b) in self.basis.iter().enumerate() {
1047            if b < n {
1048                values[b] = self.rhs[i].clone();
1049            }
1050        }
1051        values
1052    }
1053}