Skip to main content

pounce_qp/
error.rs

1//! Error and status types for the QP solver.
2
3use std::fmt;
4
5/// Terminal status of a QP solve.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum QpStatus {
8    /// KKT residual and feasibility within tolerance.
9    Optimal,
10    /// Phase-1 elastic mode certified the QP as infeasible
11    /// (residual elastic slacks are nonzero at the elastic
12    /// solution).
13    Infeasible,
14    /// Descent direction of unbounded length found (only possible
15    /// when the reduced Hessian is indefinite or negative semi-
16    /// definite along a feasible ray).
17    Unbounded,
18    /// Iteration limit reached before convergence.
19    MaxIter,
20    /// Solve-wide wall-clock limit reached before convergence.
21    TimeLimit,
22    /// Solver detected numerical breakdown (e.g., factor failure
23    /// not recoverable by inertia correction).
24    NumericalError,
25}
26
27impl fmt::Display for QpStatus {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            QpStatus::Optimal => write!(f, "optimal"),
31            QpStatus::Infeasible => write!(f, "infeasible"),
32            QpStatus::Unbounded => write!(f, "unbounded"),
33            QpStatus::MaxIter => write!(f, "max-iter"),
34            QpStatus::TimeLimit => write!(f, "time-limit"),
35            QpStatus::NumericalError => write!(f, "numerical-error"),
36        }
37    }
38}
39
40/// Hard errors — problems the solver cannot return any meaningful
41/// solution for. Soft outcomes (max-iter, infeasible, unbounded) are
42/// reported via [`QpStatus`] inside a successful
43/// [`crate::QpSolution`].
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum QpError {
46    /// Problem-data dimensions disagree (e.g., `g.len() != n`).
47    DimensionMismatch(String),
48    /// A bound vector contains `bl > bu` for some index.
49    InvertedBounds(String),
50    /// Warm-start working set has the wrong length for the problem
51    /// dimensions.
52    WarmStartDimensionMismatch(String),
53    /// Linear-solver backend reported a hard failure that cannot be
54    /// recovered by the inertia / refactor logic.
55    LinearSolverFailure(String),
56    /// Feature required by this QP is not yet implemented in the
57    /// current crate phase (e.g., one-sided inequality constraints
58    /// before the working-set machinery lands).
59    UnsupportedFeature(String),
60    /// **Internal cancellation signal**: the solve-wide wall-clock
61    /// deadline (`QpOptions::time_limit`) expired inside a routine whose
62    /// success value would otherwise be indistinguishable from a real
63    /// result — chiefly
64    /// [`factorize_with_inertia_control`](crate::solver::ParametricActiveSetSolver),
65    /// whose "success" is an in-place solved right-hand side.
66    ///
67    /// Cancellation is an error, not a value, precisely so that `?`
68    /// propagation makes every caller handle it: a timeout that returns
69    /// `Ok` leaves the caller consuming an *unsolved* KKT right-hand side
70    /// as if it were a solution, which `solve_equality_only` would then
71    /// label `Optimal`.
72    ///
73    /// This never escapes the crate. Every [`crate::QpSolver`] entry point
74    /// converts it to the soft `QpStatus::TimeLimit` outcome, so a timeout
75    /// remains a status on a successful solve for callers.
76    DeadlineExpired,
77}
78
79impl QpError {
80    /// True when this is a linear-solver failure that the §4.5
81    /// inertia-control loop may recover from by shifting the Hessian
82    /// diagonal — i.e. a singular factor or a wrong-inertia report.
83    ///
84    /// Centralizes the recoverability decision so the retry loops in
85    /// `solver.rs` and `schur.rs` don't each re-implement a fragile
86    /// substring test. The match is **case-insensitive**: some failure
87    /// messages embed the backend's `Debug`-formatted `ESymSolverStatus`
88    /// (`Singular` / `WrongInertia`, capitalized — produced by
89    /// `LinearSolver::resolve`'s catch-all `"resolve backend status:
90    /// {status:?}"`), which a bare lowercase `contains("singular")` /
91    /// `contains("inertia")` would silently miss, so those failures would
92    /// propagate as unrecoverable instead of triggering a shift retry
93    /// (L14).
94    pub fn is_recoverable_factorization_failure(&self) -> bool {
95        match self {
96            QpError::LinearSolverFailure(msg) => {
97                let m = msg.to_ascii_lowercase();
98                m.contains("inertia") || m.contains("singular")
99            }
100            _ => false,
101        }
102    }
103}
104
105impl fmt::Display for QpError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            QpError::DimensionMismatch(s) => write!(f, "dimension mismatch: {s}"),
109            QpError::InvertedBounds(s) => write!(f, "inverted bounds: {s}"),
110            QpError::WarmStartDimensionMismatch(s) => {
111                write!(f, "warm-start dimension mismatch: {s}")
112            }
113            QpError::LinearSolverFailure(s) => write!(f, "linear solver failure: {s}"),
114            QpError::UnsupportedFeature(s) => write!(f, "unsupported feature: {s}"),
115            QpError::DeadlineExpired => write!(f, "wall-clock deadline expired"),
116        }
117    }
118}
119
120impl std::error::Error for QpError {}