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