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("solver does not support native {0} constraints")]
120    UnsupportedConstraint(&'static str),
121    #[error(
122        "solver does not support native SOS1/SOS2 constraints. Explicitly reformulate them with \
123         Model::reformulate_sos or Model::to_reformulated_sos_model"
124    )]
125    UnsupportedSos,
126    #[error(
127        "solver does not support native indicator constraints. Explicitly reformulate them with Model::reformulate_indicators or Model::to_reformulated_indicator_model"
128    )]
129    UnsupportedIndicator,
130    #[error("model is missing an objective")]
131    NoObjective,
132    #[error("{location} contains a nonlinear term unsupported by this backend: {term}")]
133    Nonlinear { location: String, term: String },
134    #[error("{backend} does not support nonlinear operator {operator}")]
135    UnsupportedNonlinearOperator { backend: &'static str, operator: &'static str },
136    #[error("backend error: {0}")]
137    Backend(String),
138    #[error(transparent)]
139    Core(#[from] oximo_core::Error),
140}
141
142// Mirror `Display` in `Debug`. When a `main` returning `Result` propagates an
143// error, Rust's `Termination` impl prints it with `{:?}`. The derived `Debug`
144// would escape newlines in `Backend` messages (e.g. multi-line GAMS reports)
145// onto a single line. These messages are human-facing, so render them as-is.
146impl std::fmt::Debug for SolverError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        std::fmt::Display::fmt(self, f)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn unsupported_sos_error_points_to_explicit_reformulation_methods() {
158        let message = SolverError::UnsupportedSos.to_string();
159        assert!(message.contains("Model::reformulate_sos"));
160        assert!(message.contains("Model::to_reformulated_sos_model"));
161    }
162
163    /// The contract for a single termination: whether it admits a primal point
164    /// ([`TerminationStatus::admits_primal`]) and what [`PrimalStatus::infer`]
165    /// yields when a point is present.
166    fn contract(t: &TerminationStatus) -> (bool, PrimalStatus) {
167        use TerminationStatus as T;
168        match t {
169            T::Optimal => (true, PrimalStatus::OptimalPoint),
170            T::LocallyOptimal
171            | T::Feasible
172            | T::IterationLimit
173            | T::TimeLimit
174            | T::NodeLimit
175            | T::ObjectiveLimit
176            | T::SolutionLimit
177            | T::WorkLimit
178            | T::MemoryLimit
179            | T::Interrupted => (true, PrimalStatus::FeasiblePoint),
180            T::Infeasible
181            | T::Unbounded
182            | T::InfeasibleOrUnbounded
183            | T::LocallyInfeasible
184            | T::LicenseError
185            | T::NumericError
186            | T::NotSolved
187            | T::Other(_) => (false, PrimalStatus::FeasiblePoint),
188        }
189    }
190
191    fn all_terminations() -> Vec<TerminationStatus> {
192        use TerminationStatus as T;
193        vec![
194            T::Optimal,
195            T::LocallyOptimal,
196            T::Feasible,
197            T::Infeasible,
198            T::Unbounded,
199            T::InfeasibleOrUnbounded,
200            T::IterationLimit,
201            T::TimeLimit,
202            T::NodeLimit,
203            T::ObjectiveLimit,
204            T::SolutionLimit,
205            T::WorkLimit,
206            T::MemoryLimit,
207            T::LocallyInfeasible,
208            T::LicenseError,
209            T::Interrupted,
210            T::NumericError,
211            T::NotSolved,
212            T::Other("backend_specific".into()),
213        ]
214    }
215
216    #[test]
217    fn admits_primal_and_infer_match_contract() {
218        for t in all_terminations() {
219            let (admits, with_point) = contract(&t);
220            assert_eq!(t.admits_primal(), admits, "admits_primal for {t:?}");
221            assert_eq!(PrimalStatus::infer(&t, true), with_point, "infer(.., true) for {t:?}");
222            assert_eq!(
223                PrimalStatus::infer(&t, false),
224                PrimalStatus::NoSolution,
225                "infer(.., false) for {t:?}"
226            );
227        }
228    }
229
230    #[test]
231    fn admits_primal_drives_inference_for_status_driven_backends() {
232        for t in all_terminations() {
233            let has_point = t.admits_primal();
234            let primal = PrimalStatus::infer(&t, has_point);
235            assert_eq!(
236                primal.has_solution(),
237                has_point,
238                "has_solution mirrors admits_primal for {t:?}"
239            );
240            let expected = match (has_point, &t) {
241                (false, _) => PrimalStatus::NoSolution,
242                (true, TerminationStatus::Optimal) => PrimalStatus::OptimalPoint,
243                (true, _) => PrimalStatus::FeasiblePoint,
244            };
245            assert_eq!(primal, expected, "inferred primal for {t:?}");
246        }
247    }
248
249    #[test]
250    fn is_infeasible_covers_infeasible_and_ambiguous() {
251        use TerminationStatus as T;
252        for t in all_terminations() {
253            let expected = matches!(t, T::Infeasible | T::InfeasibleOrUnbounded);
254            assert_eq!(t.is_infeasible(), expected, "is_infeasible for {t:?}");
255        }
256    }
257
258    #[test]
259    fn primal_status_has_solution() {
260        assert!(!PrimalStatus::NoSolution.has_solution());
261        assert!(PrimalStatus::FeasiblePoint.has_solution());
262        assert!(PrimalStatus::OptimalPoint.has_solution());
263    }
264}