Skip to main content

otspot_core/simplex/
entry.rs

1//! Simplex public entry + presolve/postsolve orchestration + method dispatch.
2
3use crate::options::{SimplexMethod, SolverOptions, WarmStartBasis};
4use crate::presolve;
5use crate::problem::{LpProblem, SolveStatus, SolverResult};
6use crate::qp::certificate::guard_lp_optimal;
7use crate::tolerances::PIVOT_TOL;
8
9use super::dual;
10use super::dual_advanced;
11use super::primal::two_phase_simplex;
12use super::standard_form::build_standard_form;
13
14/// Solve an LP with default options.
15pub fn solve(problem: &LpProblem) -> SolverResult {
16    solve_with(problem, &SolverOptions::default())
17}
18
19/// Solve an LP with the supplied options. When `options.presolve` is set,
20/// presolve runs before the simplex.
21///
22/// Returns [`SolveStatus::NumericalError`] immediately if `options` fails
23/// validation (invalid tolerance, zero threads, etc.).
24pub fn solve_with(problem: &LpProblem, options: &SolverOptions) -> SolverResult {
25    if options.validate().is_err() {
26        return SolverResult::numerical_error();
27    }
28    // timeout_secs → deadline (mirrors qp_solve_impl).
29    let mut opts_with_deadline;
30    let options = if let (Some(secs), true) = (options.timeout_secs, options.deadline.is_none()) {
31        opts_with_deadline = options.clone();
32        opts_with_deadline.deadline = Some(
33            std::time::Instant::now() + std::time::Duration::from_secs_f64(secs),
34        );
35        &opts_with_deadline
36    } else {
37        options
38    };
39
40    let prof_t0 = std::time::Instant::now();
41    // Presolve elapsed time when presolve ran but did not reduce the problem.
42    // Used to set timing_breakdown on the fallthrough solve_without_presolve path.
43    let mut non_reduced_presolve_us: Option<u64> = None;
44
45    if options.presolve {
46        match presolve::run_presolve(problem, options.deadline) {
47            Err(presolve::PresolveStatus::Infeasible) => {
48                return SolverResult {
49                    status: SolveStatus::Infeasible,
50                    objective: 0.0,
51                    solution: vec![],
52                    dual_solution: vec![],
53                    reduced_costs: vec![],
54                    slack: vec![],
55                    warm_start_basis: None,
56            ..Default::default()
57                };
58            }
59            Err(presolve::PresolveStatus::Unbounded) => {
60                return SolverResult {
61                    status: SolveStatus::Unbounded,
62                    objective: f64::NEG_INFINITY,
63                    solution: vec![],
64                    dual_solution: vec![],
65                    reduced_costs: vec![],
66                    slack: vec![],
67                    warm_start_basis: None,
68            ..Default::default()
69                };
70            }
71            Ok(presolve_result) if presolve_result.was_reduced => {
72                // Presolve renumbers variables, so a supplied warm_start is invalidated.
73                let opts_no_ws = if options.warm_start.is_some() {
74                    let mut o = options.clone();
75                    o.warm_start = None;
76                    o.presolve = false;
77                    Some(o)
78                } else {
79                    None
80                };
81                let eff_opts = opts_no_ws.as_ref().unwrap_or(options);
82                let t_presolve_done = std::time::Instant::now();
83                let presolve_us = t_presolve_done.duration_since(prof_t0).as_micros() as u64;
84                let raw = solve_without_presolve(&presolve_result.reduced_problem, eff_opts);
85                let t_solve_done = std::time::Instant::now();
86                let solve_us = t_solve_done.duration_since(t_presolve_done).as_micros() as u64;
87                // The reduced LP can be unsolvable while the original is fine
88                // (SingularBasis on the reduced initial basis, Eq drift in Phase II,
89                // or guard_lp_optimal catching a KKT failure on the reduced form).
90                // SuboptimalSolution from the guard means KKT failed → fall back.
91                if matches!(raw.status, SolveStatus::NumericalError | SolveStatus::SuboptimalSolution) {
92                    return solve_without_presolve(problem, options);
93                }
94                let mut res = presolve::postsolve::run_postsolve(
95                    &raw,
96                    &presolve_result,
97                    problem,
98                    eff_opts.deadline,
99                    options.recover_warm_start_basis,
100                );
101                res = guard_lp_optimal(res, problem);
102                let postsolve_us = t_solve_done.elapsed().as_micros() as u64;
103                res.timing_breakdown = Some(crate::problem::TimingBreakdown {
104                    presolve_us, solve_us, postsolve_us,
105                    ..Default::default()
106                });
107                // Postsolve dfeas above PIVOT_TOL (or guard-caught KKT failure) means
108                // dual-recovery cannot reconstruct the structure presolve removed.
109                // The original LP solves cleanly, so re-attempt on the remaining deadline.
110                let postsolve_bad = res.postsolve_dfeas.is_some_and(|d| d > PIVOT_TOL)
111                    || res.status == SolveStatus::SuboptimalSolution;
112                if matches!(res.status, SolveStatus::Optimal | SolveStatus::SuboptimalSolution)
113                    && postsolve_bad
114                {
115                    let deadline_ok = options.deadline
116                        .is_none_or(|d| std::time::Instant::now() < d);
117                    if deadline_ok {
118                        let mut opts_off = options.clone();
119                        opts_off.presolve = false;
120                        // Force primal: 初回試行で feasibility 既知のため Primal で直行。
121                        opts_off.simplex_method = crate::options::SimplexMethod::Primal;
122                        let t_alt_start = std::time::Instant::now();
123                        let mut alt = solve_without_presolve(problem, &opts_off);
124                        let alt_solve_us = t_alt_start.elapsed().as_micros() as u64;
125                        if alt.status == SolveStatus::Optimal
126                            && alt.postsolve_dfeas.is_none()
127                            && alt.objective.is_finite()
128                        {
129                            // Preserve the original presolve/postsolve times: both phases
130                            // ran (even if postsolve produced bad duals); only solve_us
131                            // reflects the alt direct-solve.
132                            alt.timing_breakdown = Some(crate::problem::TimingBreakdown {
133                                presolve_us,
134                                solve_us: alt_solve_us,
135                                postsolve_us,
136                                ..Default::default()
137                            });
138                            return alt;
139                        }
140                    }
141                }
142                return res;
143            }
144            Ok(_) => {
145                // Presolve did not reduce; record elapsed for timing_breakdown below.
146                non_reduced_presolve_us = Some(prof_t0.elapsed().as_micros() as u64);
147            }
148        }
149    }
150
151    // Catch deadline overrun before build_standard_form (presolve may have
152    // returned early without reducing).
153    if options.deadline.is_some_and(|d| std::time::Instant::now() >= d) {
154        return SolverResult {
155            status: SolveStatus::Timeout,
156            objective: f64::INFINITY,
157            solution: vec![],
158            dual_solution: vec![],
159            reduced_costs: vec![],
160            slack: vec![],
161            warm_start_basis: None,
162            ..Default::default()
163        };
164    }
165
166    let t_solve_start = std::time::Instant::now();
167    let mut result = solve_without_presolve(problem, options);
168    if let Some(presolve_us) = non_reduced_presolve_us {
169        let solve_us = t_solve_start.elapsed().as_micros() as u64;
170        result.timing_breakdown = Some(crate::problem::TimingBreakdown {
171            presolve_us,
172            solve_us,
173            postsolve_us: 0,
174            ..Default::default()
175        });
176    }
177    result
178}
179
180/// Solve without presolve.
181pub(crate) fn solve_without_presolve(problem: &LpProblem, options: &SolverOptions) -> SolverResult {
182    let m = problem.num_constraints;
183    let n = problem.num_vars;
184
185    if n == 0 {
186        for i in 0..m {
187            if problem.b[i] < -options.primal_tol {
188                return SolverResult {
189                    status: SolveStatus::Infeasible,
190                    objective: 0.0,
191                    solution: vec![],
192                    dual_solution: vec![],
193                    reduced_costs: vec![],
194                    slack: vec![],
195                    warm_start_basis: None,
196            ..Default::default()
197                };
198            }
199        }
200        return SolverResult {
201            status: SolveStatus::Optimal,
202            objective: 0.0,
203            solution: vec![],
204            dual_solution: vec![0.0; m],
205            reduced_costs: vec![],
206            slack: problem.b.clone(),
207            warm_start_basis: None,
208            ..Default::default()
209        };
210    }
211
212    if m == 0 {
213        let mut x = vec![0.0; n];
214        let mut obj = 0.0;
215        for (j, x_j) in x.iter_mut().enumerate() {
216            if problem.c[j] < -options.primal_tol {
217                // Finite upper bound caps the maximizer; infinite ⇒ Unbounded.
218                let ub = problem.bounds[j].1;
219                if ub.is_infinite() {
220                    return SolverResult {
221                        status: SolveStatus::Unbounded,
222                        objective: f64::NEG_INFINITY,
223                        solution: vec![],
224                        dual_solution: vec![],
225                        reduced_costs: vec![],
226                        slack: vec![],
227                        warm_start_basis: None,
228            ..Default::default()
229                    };
230                }
231                *x_j = ub;
232            }
233            obj += problem.c[j] * *x_j;
234        }
235        return SolverResult {
236            status: SolveStatus::Optimal,
237            objective: obj,
238            solution: x,
239            dual_solution: vec![],
240            reduced_costs: problem.c.clone(),
241            slack: vec![],
242            warm_start_basis: None,
243            ..Default::default()
244        };
245    }
246
247    let sf = build_standard_form(problem);
248
249    // Copy warm_start_lp.basis into warm_start so the LP-specific slot feeds
250    // the existing simplex warm path.
251    let warm_lp_opts;
252    let options = if let Some(ws_lp) = options.warm_start_lp.as_ref() {
253        if options.warm_start.is_none() {
254            warm_lp_opts = SolverOptions {
255                warm_start: Some(WarmStartBasis {
256                    basis: ws_lp.basis.clone(),
257                    x_b: Vec::new(),
258                }),
259                ..options.clone()
260            };
261            &warm_lp_opts
262        } else {
263            options
264        }
265    } else {
266        options
267    };
268
269    let result = match options.simplex_method {
270        SimplexMethod::Primal => two_phase_simplex(&sf, problem, options),
271        SimplexMethod::Dual => dual::two_phase_dual_simplex(&sf, problem, options),
272        SimplexMethod::DualAdvanced | SimplexMethod::Auto => {
273            // Auto uses dual_advanced; it falls back to two_phase_dual_simplex
274            // internally for problems with Ge/Eq constraints.
275            dual_advanced::solve_dual_advanced(&sf, problem, options)
276        }
277    };
278    guard_lp_optimal(result, problem)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::problem::ConstraintType;
285    use crate::sparse::CscMatrix;
286
287    fn make_trivial_lp() -> LpProblem {
288        // minimize x  s.t.  x <= 5,  x >= 0
289        let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
290        LpProblem::new_general(
291            vec![1.0],
292            a,
293            vec![5.0],
294            vec![ConstraintType::Le],
295            vec![(0.0, f64::INFINITY)],
296            None,
297        )
298        .unwrap()
299    }
300
301    /// guard_lp_optimal demotes a corrupt Optimal (x = 1e12 >> b = 5) to SuboptimalSolution.
302    /// Primal feasibility and stationarity both fail prove_optimal_lp at LP_CERT_TOL.
303    #[test]
304    fn guard_lp_optimal_catches_corrupt_result() {
305        let lp = make_trivial_lp();
306        let corrupt = SolverResult {
307            status: SolveStatus::Optimal,
308            objective: 1e12,
309            solution: vec![1e12],
310            dual_solution: vec![0.0],
311            reduced_costs: vec![0.0],
312            slack: vec![0.0],
313            ..Default::default()
314        };
315        let guarded = guard_lp_optimal(corrupt, &lp);
316        assert_eq!(
317            guarded.status,
318            SolveStatus::SuboptimalSolution,
319            "guard must demote false-Optimal with |Ax-b| >> tol to SuboptimalSolution"
320        );
321    }
322
323    /// guard_lp_optimal is a no-op for non-Optimal statuses.
324    #[test]
325    fn guard_lp_optimal_passthrough_non_optimal() {
326        let lp = make_trivial_lp();
327        for status in [SolveStatus::Infeasible, SolveStatus::Timeout, SolveStatus::NumericalError] {
328            let r = SolverResult { status: status.clone(), ..Default::default() };
329            let out = guard_lp_optimal(r, &lp);
330            assert_eq!(out.status, status, "guard must pass through {status:?}");
331        }
332    }
333
334    // min x + y  s.t.  2x + y >= 3,  x + 2y >= 3,  x,y >= 0
335    // Ge constraints: dual-fix cannot push x,y to 0 (would violate Ge).
336    // No singleton rows, no Eq rows, no free vars, no parallel rows → presolve
337    // cannot remove any row or column ⇒ was_reduced=false.
338    fn make_non_reducible_lp() -> LpProblem {
339        let a = CscMatrix::from_triplets(
340            &[0, 1, 0, 1],
341            &[0, 0, 1, 1],
342            &[2.0, 1.0, 1.0, 2.0],
343            2, 2,
344        ).unwrap();
345        LpProblem::new_general(
346            vec![1.0, 1.0],
347            a,
348            vec![3.0, 3.0],
349            vec![ConstraintType::Ge, ConstraintType::Ge],
350            vec![(0.0, f64::INFINITY), (0.0, f64::INFINITY)],
351            None,
352        ).unwrap()
353    }
354
355    // min x + y  s.t.  x = 2 (singleton Eq),  x + y <= 5,  x,y >= 0
356    // Singleton equality row fixes x — presolve reduces the problem.
357    fn make_reducible_lp() -> LpProblem {
358        let a = CscMatrix::from_triplets(
359            &[0, 1, 1],
360            &[0, 0, 1],
361            &[1.0, 1.0, 1.0],
362            2, 2,
363        ).unwrap();
364        LpProblem::new_general(
365            vec![1.0, 1.0],
366            a,
367            vec![2.0, 5.0],
368            vec![ConstraintType::Eq, ConstraintType::Le],
369            vec![(0.0, f64::INFINITY), (0.0, f64::INFINITY)],
370            None,
371        ).unwrap()
372    }
373
374    /// timing_breakdown is Some on the was_reduced=false path (non-reducing presolve).
375    #[test]
376    fn timing_breakdown_set_when_presolve_does_not_reduce() {
377        let lp = make_non_reducible_lp();
378
379        // Confirm this LP actually exercises the was_reduced=false path.
380        let pr = crate::presolve::run_presolve(&lp, None)
381            .expect("non-reducible LP must not be Infeasible/Unbounded at presolve");
382        assert!(
383            !pr.was_reduced,
384            "make_non_reducible_lp() must produce an LP presolve cannot reduce (was_reduced must be false)"
385        );
386
387        let opts = SolverOptions { presolve: true, ..SolverOptions::default() };
388        let result = solve_with(&lp, &opts);
389        assert_eq!(result.status, SolveStatus::Optimal);
390        assert!(
391            result.timing_breakdown.is_some(),
392            "timing_breakdown must be Some even when presolve does not reduce the problem"
393        );
394    }
395
396    /// timing_breakdown is Some on the was_reduced=true path (reducing presolve).
397    #[test]
398    fn timing_breakdown_set_when_presolve_reduces() {
399        let lp = make_reducible_lp();
400        let opts = SolverOptions { presolve: true, ..SolverOptions::default() };
401        let result = solve_with(&lp, &opts);
402        assert_eq!(result.status, SolveStatus::Optimal);
403        assert!(
404            result.timing_breakdown.is_some(),
405            "timing_breakdown must be Some when presolve reduces the problem"
406        );
407        // is_some() is the load-bearing assertion; individual μs values can round
408        // to zero on fast machines for this trivial LP.
409        let _tb = result.timing_breakdown.unwrap();
410    }
411
412    /// Invalid options are rejected at the simplex entry with NumericalError.
413    ///
414    /// Wiring sentinel: removing the `validate()` call from `solve_with` causes
415    /// all cases to panic or produce wrong status instead of NumericalError.
416    #[test]
417    fn invalid_options_rejected_at_simplex_entry() {
418        let lp = make_trivial_lp();
419        let cases: &[(&str, SolverOptions)] = &[
420            ("nan primal_tol", SolverOptions { primal_tol: f64::NAN, ..Default::default() }),
421            ("zero primal_tol", SolverOptions { primal_tol: 0.0, ..Default::default() }),
422            ("neg dual_tol", SolverOptions { dual_tol: -1.0, ..Default::default() }),
423            ("inf timeout", SolverOptions { timeout_secs: Some(f64::INFINITY), ..Default::default() }),
424            ("neg timeout", SolverOptions { timeout_secs: Some(-1.0), ..Default::default() }),
425            ("zero threads", SolverOptions { threads: 0, ..Default::default() }),
426        ];
427        for (label, opts) in cases {
428            let result = solve_with(&lp, opts);
429            assert_eq!(
430                result.status,
431                SolveStatus::NumericalError,
432                "simplex::solve_with with {label} must return NumericalError"
433            );
434        }
435    }
436}