1use thiserror::Error;
2
3#[derive(Clone, Debug, PartialEq, Eq)]
5pub enum TerminationStatus {
6 Optimal,
8 LocallyOptimal,
10 Feasible,
12 Infeasible,
14 Unbounded,
16 InfeasibleOrUnbounded,
18 IterationLimit,
20 TimeLimit,
22 NodeLimit,
24 ObjectiveLimit,
26 SolutionLimit,
28 WorkLimit,
30 MemoryLimit,
32 LocallyInfeasible,
34 LicenseError,
36 Interrupted,
38 NumericError,
40 NotSolved,
42 Other(String),
44}
45
46impl TerminationStatus {
47 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 #[must_use]
74 pub fn is_infeasible(&self) -> bool {
75 matches!(self, Self::Infeasible | Self::InfeasibleOrUnbounded)
76 }
77}
78
79#[derive(Copy, Clone, Debug, PartialEq, Eq)]
84pub enum PrimalStatus {
85 NoSolution,
87 FeasiblePoint,
89 OptimalPoint,
91}
92
93impl PrimalStatus {
94 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 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
142impl 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 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}