Skip to main content

oximo_solver/
status.rs

1use thiserror::Error;
2
3/// Why a solver stopped, independent of whether a usable point was returned.
4#[derive(Clone, Debug, PartialEq, Eq)]
5pub enum TerminationStatus {
6    /// Proven globally optimal.
7    Optimal,
8    /// A local optimum.
9    LocallyOptimal,
10    /// Proven infeasible.
11    Infeasible,
12    /// Proven unbounded.
13    Unbounded,
14    /// Infeasible or unbounded, the backend can't differentiate.
15    InfeasibleOrUnbounded,
16    /// Stopped at an iteration limit.
17    IterationLimit,
18    /// Stopped at a time limit.
19    TimeLimit,
20    /// Stopped at a branch-and-bound node limit.
21    NodeLimit,
22    /// Stopped by an external interrupt or solver-specific user limit.
23    Interrupted,
24    /// The solver hit a numerical problem (singular basis, presolve error, ...).
25    NumericError,
26    /// No solve has been attempted yet.
27    NotSolved,
28    /// A backend status with no direct mapping. Carries the raw label.
29    Other(String),
30}
31
32impl TerminationStatus {
33    /// Whether a solver that stopped for this reason may still return a usable
34    /// primal point. `true` for optimality and for the various limits (which
35    /// keep the best incumbent found so far), `false` for infeasible/unbounded/
36    /// error/unsolved states.
37    pub fn admits_primal(&self) -> bool {
38        matches!(
39            self,
40            Self::Optimal
41                | Self::LocallyOptimal
42                | Self::IterationLimit
43                | Self::TimeLimit
44                | Self::NodeLimit
45                | Self::Interrupted
46        )
47    }
48}
49
50/// The status of the primal point held in a [`crate::SolverResult`].
51///
52/// Decoupled from [`TerminationStatus`] so a result that stopped at a limit can
53/// still carry a usable incumbent.
54#[derive(Copy, Clone, Debug, PartialEq, Eq)]
55pub enum PrimalStatus {
56    /// No primal point is available.
57    NoSolution,
58    /// A feasible point is available, but not proven optimal.
59    FeasiblePoint,
60    /// A proven-optimal point is available.
61    OptimalPoint,
62}
63
64impl PrimalStatus {
65    /// Classify the primal status from the termination reason and whether a
66    /// point was actually returned. `Optimal` termination with a point yields
67    /// [`PrimalStatus::OptimalPoint`]; any other termination with a point yields
68    /// [`PrimalStatus::FeasiblePoint`]; no point yields
69    /// [`PrimalStatus::NoSolution`].
70    pub fn infer(termination: &TerminationStatus, has_point: bool) -> Self {
71        if !has_point {
72            Self::NoSolution
73        } else if matches!(termination, TerminationStatus::Optimal) {
74            Self::OptimalPoint
75        } else {
76            Self::FeasiblePoint
77        }
78    }
79
80    /// Whether a usable primal point is available.
81    pub fn has_solution(self) -> bool {
82        !matches!(self, Self::NoSolution)
83    }
84}
85
86#[derive(Error)]
87pub enum SolverError {
88    #[error("solver does not support model kind {0:?}")]
89    UnsupportedKind(oximo_core::ModelKind),
90    #[error("model is missing an objective")]
91    NoObjective,
92    #[error("nonlinear constructs are not supported by this backend")]
93    Nonlinear,
94    #[error("backend error: {0}")]
95    Backend(String),
96    #[error(transparent)]
97    Core(#[from] oximo_core::Error),
98}
99
100// Mirror `Display` in `Debug`. When a `main` returning `Result` propagates an
101// error, Rust's `Termination` impl prints it with `{:?}`. The derived `Debug`
102// would escape newlines in `Backend` messages (e.g. multi-line GAMS reports)
103// onto a single line. These messages are human-facing, so render them as-is.
104impl std::fmt::Debug for SolverError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        std::fmt::Display::fmt(self, f)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    /// The contract for a single termination: whether it admits a primal point
115    /// ([`TerminationStatus::admits_primal`]) and what [`PrimalStatus::infer`]
116    /// yields when a point is present.
117    fn contract(t: &TerminationStatus) -> (bool, PrimalStatus) {
118        use TerminationStatus as T;
119        match t {
120            T::Optimal => (true, PrimalStatus::OptimalPoint),
121            T::LocallyOptimal
122            | T::IterationLimit
123            | T::TimeLimit
124            | T::NodeLimit
125            | T::Interrupted => (true, PrimalStatus::FeasiblePoint),
126            T::Infeasible
127            | T::Unbounded
128            | T::InfeasibleOrUnbounded
129            | T::NumericError
130            | T::NotSolved
131            | T::Other(_) => (false, PrimalStatus::FeasiblePoint),
132        }
133    }
134
135    fn all_terminations() -> Vec<TerminationStatus> {
136        use TerminationStatus as T;
137        vec![
138            T::Optimal,
139            T::LocallyOptimal,
140            T::Infeasible,
141            T::Unbounded,
142            T::InfeasibleOrUnbounded,
143            T::IterationLimit,
144            T::TimeLimit,
145            T::NodeLimit,
146            T::Interrupted,
147            T::NumericError,
148            T::NotSolved,
149            T::Other("backend_specific".into()),
150        ]
151    }
152
153    #[test]
154    fn admits_primal_and_infer_match_contract() {
155        for t in all_terminations() {
156            let (admits, with_point) = contract(&t);
157            assert_eq!(t.admits_primal(), admits, "admits_primal for {t:?}");
158            assert_eq!(PrimalStatus::infer(&t, true), with_point, "infer(.., true) for {t:?}");
159            assert_eq!(
160                PrimalStatus::infer(&t, false),
161                PrimalStatus::NoSolution,
162                "infer(.., false) for {t:?}"
163            );
164        }
165    }
166
167    #[test]
168    fn admits_primal_drives_inference_for_status_driven_backends() {
169        for t in all_terminations() {
170            let has_point = t.admits_primal();
171            let primal = PrimalStatus::infer(&t, has_point);
172            assert_eq!(
173                primal.has_solution(),
174                has_point,
175                "has_solution mirrors admits_primal for {t:?}"
176            );
177            let expected = match (has_point, &t) {
178                (false, _) => PrimalStatus::NoSolution,
179                (true, TerminationStatus::Optimal) => PrimalStatus::OptimalPoint,
180                (true, _) => PrimalStatus::FeasiblePoint,
181            };
182            assert_eq!(primal, expected, "inferred primal for {t:?}");
183        }
184    }
185
186    #[test]
187    fn primal_status_has_solution() {
188        assert!(!PrimalStatus::NoSolution.has_solution());
189        assert!(PrimalStatus::FeasiblePoint.has_solution());
190        assert!(PrimalStatus::OptimalPoint.has_solution());
191    }
192}