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    /// Stopped with a feasible point, without any optimality claim.
11    Feasible,
12    /// Proven infeasible.
13    Infeasible,
14    /// Proven unbounded.
15    Unbounded,
16    /// Infeasible or unbounded, the backend can't differentiate.
17    InfeasibleOrUnbounded,
18    /// Stopped at an iteration limit.
19    IterationLimit,
20    /// Stopped at a time limit.
21    TimeLimit,
22    /// Stopped at a branch-and-bound node limit.
23    NodeLimit,
24    /// Stopped after reaching an objective cutoff or target.
25    ObjectiveLimit,
26    /// Stopped after finding the requested number of solutions.
27    SolutionLimit,
28    /// Stopped at a solver-defined work limit.
29    WorkLimit,
30    /// Stopped because the solver exhausted its memory limit.
31    MemoryLimit,
32    /// Converged to a locally infeasible point.
33    LocallyInfeasible,
34    /// The solver could not run because no usable license was available.
35    LicenseError,
36    /// Stopped by a genuine user or external interrupt.
37    Interrupted,
38    /// The solver hit a numerical problem (singular basis, presolve error, ...).
39    NumericError,
40    /// No solve has been attempted yet.
41    NotSolved,
42    /// A backend status with no direct mapping. Carries the raw label.
43    Other(String),
44}
45
46impl TerminationStatus {
47    /// Whether a solver that stopped for this reason may still return a usable
48    /// primal point. `true` for optimality, plain feasibility, and the various
49    /// limits (which keep the best incumbent found so far), `false` for
50    /// infeasible/unbounded/error/unsolved states.
51    pub fn admits_primal(&self) -> bool {
52        matches!(
53            self,
54            Self::Optimal
55                | Self::LocallyOptimal
56                | Self::Feasible
57                | Self::IterationLimit
58                | Self::TimeLimit
59                | Self::NodeLimit
60                | Self::ObjectiveLimit
61                | Self::SolutionLimit
62                | Self::WorkLimit
63                | Self::MemoryLimit
64                | Self::Interrupted
65        )
66    }
67
68    /// Whether this status proves the model infeasible, so a conflict/IIS
69    /// diagnosis is meaningful. Treats the ambiguous [`InfeasibleOrUnbounded`]
70    /// as infeasible.
71    ///
72    /// [`InfeasibleOrUnbounded`]: Self::InfeasibleOrUnbounded
73    #[must_use]
74    pub fn is_infeasible(&self) -> bool {
75        matches!(self, Self::Infeasible | Self::InfeasibleOrUnbounded)
76    }
77}
78
79/// The status of the primal point held in a [`crate::SolverResult`].
80///
81/// Decoupled from [`TerminationStatus`] so a result that stopped at a limit can
82/// still carry a usable incumbent.
83#[derive(Copy, Clone, Debug, PartialEq, Eq)]
84pub enum PrimalStatus {
85    /// No primal point is available.
86    NoSolution,
87    /// A feasible point is available, but not proven optimal.
88    FeasiblePoint,
89    /// A proven-optimal point is available.
90    OptimalPoint,
91}
92
93impl PrimalStatus {
94    /// Classify the primal status from the termination reason and whether a
95    /// point was actually returned. `Optimal` termination with a point yields
96    /// [`PrimalStatus::OptimalPoint`]; any other termination with a point yields
97    /// [`PrimalStatus::FeasiblePoint`]; no point yields
98    /// [`PrimalStatus::NoSolution`].
99    pub fn infer(termination: &TerminationStatus, has_point: bool) -> Self {
100        if !has_point {
101            Self::NoSolution
102        } else if matches!(termination, TerminationStatus::Optimal) {
103            Self::OptimalPoint
104        } else {
105            Self::FeasiblePoint
106        }
107    }
108
109    /// Whether a usable primal point is available.
110    pub fn has_solution(self) -> bool {
111        !matches!(self, Self::NoSolution)
112    }
113}
114
115#[derive(Error)]
116pub enum SolverError {
117    #[error("solver does not support model kind {0:?}")]
118    UnsupportedKind(oximo_core::ModelKind),
119    #[error("model is missing an objective")]
120    NoObjective,
121    #[error("{location} contains a nonlinear term unsupported by this backend: {term}")]
122    Nonlinear { location: String, term: String },
123    #[error("backend error: {0}")]
124    Backend(String),
125    #[error(transparent)]
126    Core(#[from] oximo_core::Error),
127}
128
129// Mirror `Display` in `Debug`. When a `main` returning `Result` propagates an
130// error, Rust's `Termination` impl prints it with `{:?}`. The derived `Debug`
131// would escape newlines in `Backend` messages (e.g. multi-line GAMS reports)
132// onto a single line. These messages are human-facing, so render them as-is.
133impl std::fmt::Debug for SolverError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        std::fmt::Display::fmt(self, f)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// The contract for a single termination: whether it admits a primal point
144    /// ([`TerminationStatus::admits_primal`]) and what [`PrimalStatus::infer`]
145    /// yields when a point is present.
146    fn contract(t: &TerminationStatus) -> (bool, PrimalStatus) {
147        use TerminationStatus as T;
148        match t {
149            T::Optimal => (true, PrimalStatus::OptimalPoint),
150            T::LocallyOptimal
151            | T::Feasible
152            | T::IterationLimit
153            | T::TimeLimit
154            | T::NodeLimit
155            | T::ObjectiveLimit
156            | T::SolutionLimit
157            | T::WorkLimit
158            | T::MemoryLimit
159            | T::Interrupted => (true, PrimalStatus::FeasiblePoint),
160            T::Infeasible
161            | T::Unbounded
162            | T::InfeasibleOrUnbounded
163            | T::LocallyInfeasible
164            | T::LicenseError
165            | T::NumericError
166            | T::NotSolved
167            | T::Other(_) => (false, PrimalStatus::FeasiblePoint),
168        }
169    }
170
171    fn all_terminations() -> Vec<TerminationStatus> {
172        use TerminationStatus as T;
173        vec![
174            T::Optimal,
175            T::LocallyOptimal,
176            T::Feasible,
177            T::Infeasible,
178            T::Unbounded,
179            T::InfeasibleOrUnbounded,
180            T::IterationLimit,
181            T::TimeLimit,
182            T::NodeLimit,
183            T::ObjectiveLimit,
184            T::SolutionLimit,
185            T::WorkLimit,
186            T::MemoryLimit,
187            T::LocallyInfeasible,
188            T::LicenseError,
189            T::Interrupted,
190            T::NumericError,
191            T::NotSolved,
192            T::Other("backend_specific".into()),
193        ]
194    }
195
196    #[test]
197    fn admits_primal_and_infer_match_contract() {
198        for t in all_terminations() {
199            let (admits, with_point) = contract(&t);
200            assert_eq!(t.admits_primal(), admits, "admits_primal for {t:?}");
201            assert_eq!(PrimalStatus::infer(&t, true), with_point, "infer(.., true) for {t:?}");
202            assert_eq!(
203                PrimalStatus::infer(&t, false),
204                PrimalStatus::NoSolution,
205                "infer(.., false) for {t:?}"
206            );
207        }
208    }
209
210    #[test]
211    fn admits_primal_drives_inference_for_status_driven_backends() {
212        for t in all_terminations() {
213            let has_point = t.admits_primal();
214            let primal = PrimalStatus::infer(&t, has_point);
215            assert_eq!(
216                primal.has_solution(),
217                has_point,
218                "has_solution mirrors admits_primal for {t:?}"
219            );
220            let expected = match (has_point, &t) {
221                (false, _) => PrimalStatus::NoSolution,
222                (true, TerminationStatus::Optimal) => PrimalStatus::OptimalPoint,
223                (true, _) => PrimalStatus::FeasiblePoint,
224            };
225            assert_eq!(primal, expected, "inferred primal for {t:?}");
226        }
227    }
228
229    #[test]
230    fn is_infeasible_covers_infeasible_and_ambiguous() {
231        use TerminationStatus as T;
232        for t in all_terminations() {
233            let expected = matches!(t, T::Infeasible | T::InfeasibleOrUnbounded);
234            assert_eq!(t.is_infeasible(), expected, "is_infeasible for {t:?}");
235        }
236    }
237
238    #[test]
239    fn primal_status_has_solution() {
240        assert!(!PrimalStatus::NoSolution.has_solution());
241        assert!(PrimalStatus::FeasiblePoint.has_solution());
242        assert!(PrimalStatus::OptimalPoint.has_solution());
243    }
244}