Skip to main content

spectrafit_solver/
error.rs

1//! Solver-level error type.
2//!
3//! [`SolverError`] is defined as the typed error shape for boundary-crossing
4//! solver failures. An audit found that all severity-9 `expect()` /
5//! `unwrap()` sites in spectrafit-solver were in test code; the single
6//! production site (the `expect("INVARIANT: both values are finite")` in
7//! `global.rs`'s `select_best_individual`) is an INVARIANT-guarded call.
8//! SolverError variants are available for new boundary-facing code;
9//! widespread conversion of existing `CoreError` returns remains a
10//! follow-up, not yet done.
11
12use spectrafit_types::CoreError;
13use thiserror::Error;
14
15/// Errors originating in the solver layer.
16#[derive(Debug, Error)]
17pub enum SolverError {
18    /// A free-key or parameter lookup inside the dispatch path failed.
19    ///
20    /// Typically caused by malformed graph input (a `free_key` that does not
21    /// match `"node_id.param_name"` format, or a node/parameter that the
22    /// compiled graph promised but the spec does not contain).
23    #[error("dispatch error: {0}")]
24    Dispatch(String),
25
26    /// The global optimiser (Differential Evolution) could not produce a
27    /// finite-cost candidate, e.g. because all population members diverged.
28    #[error("global optimisation failed: {0}")]
29    GlobalFailure(String),
30
31    /// The post-fit covariance matrix is ill-conditioned (Cholesky failed or
32    /// the condition number κ was above the safe threshold).
33    #[error("postfit covariance ill-conditioned: κ={kappa:e}")]
34    IllConditioned {
35        /// The condition number κ = σ_max / σ_min of JᵀJ at the solution.
36        kappa: f64,
37    },
38
39    /// An IRLS weight-update iteration encountered a numerical failure.
40    #[error("irls weight update failed: {0}")]
41    IrlsFailure(String),
42
43    /// The graph is not separable but VarPro was explicitly requested.
44    #[error("solver='varpro' requested but graph is not separable")]
45    VarproNotSeparable,
46
47    /// VarPro cannot honour tied parameters (from `expr_edges` or `Parameter.expr`).
48    #[error(
49        "solver='varpro' does not support tied parameters (expr_edges or \
50         Parameter.expr); use solver='lm', 'trf', or 'geodesic'"
51    )]
52    VarproExprEdgesUnsupported,
53
54    /// VarPro cannot honour per-dataset (`dataset_index`) node scoping.
55    #[error(
56        "solver='varpro' does not support per-dataset (dataset_index) node \
57         scoping for simultaneous multi-dataset fits; use solver='lm', \
58         'trf', or 'geodesic'"
59    )]
60    VarproDatasetScopingUnsupported,
61
62    /// A tied target had a malformed `"node.param"` key.
63    #[error("malformed tied target '{0}'")]
64    MalformedTiedTarget(String),
65
66    /// A tied target referenced a node not present in the compiled graph.
67    #[error("tied target node '{0}' not found")]
68    TiedTargetNodeMissing(String),
69
70    /// A tied target referenced a parameter not present on the named node.
71    #[error("tied target param '{0}' not found")]
72    TiedTargetParamMissing(String),
73
74    /// `FitOptionsSpec.solver` did not match any recognised solver name.
75    ///
76    /// Previously an unrecognised string (e.g. a typo like `"lmm"` or a
77    /// wrong-case `"Trf"`) silently fell back to the LM default instead of
78    /// erroring, which could mask a misconfigured fit with a plausible-looking
79    /// but unintended result.
80    #[error(
81        "unrecognised solver '{0}' — expected one of: \"lm\", \"lm-legacy\", \"trf\", \
82         \"geodesic\", \"dogleg\", \"newton-cg\", \"varpro\", \"auto\", \"global\", \
83         \"irls\" (optionally \"irls:huber\"/\"irls:bisquare\"/\"irls:cauchy\")"
84    )]
85    UnrecognisedSolver(String),
86
87    /// The `<weight>` suffix of an `\"irls:<weight>\"` solver string did not
88    /// match any recognised robust weight function.
89    ///
90    /// Previously an unrecognised suffix (e.g. `\"irls:buisquare\"`) silently
91    /// fell back to Huber instead of erroring.
92    #[error("unrecognised irls weight function '{0}' — expected one of: \"huber\", \"bisquare\" (or \"biweight\"), \"cauchy\"")]
93    UnrecognisedWeightFn(String),
94
95    /// `FitOptionsSpec.eta` (the Dogleg / Newton-CG step-acceptance
96    /// threshold) was not finite and in `[0.0, 0.25)`.
97    ///
98    /// `TrustRegionConfig::eta` only shrinks the trust-region radius when
99    /// `ρ < 0.25`; an `eta >= 0.25` opens a band in which a step is rejected
100    /// without shrinking the radius, so the driver can spin to `max_nfev`
101    /// instead of failing cleanly. The driver itself only guards this with a
102    /// `debug_assert!`, which compiles out of release builds, so this is the
103    /// check that actually enforces the bound for callers that reach the
104    /// Rust core directly (the JSON `fit` entrypoint, not just the Pydantic
105    /// `FitOptions` validator).
106    #[error("eta must be finite and in [0.0, 0.25); got {0}")]
107    InvalidEta(f64),
108}
109
110/// Map [`SolverError`] to the workspace-wide [`CoreError`].
111///
112/// Symmetric to `From<GraphError> for CoreError` in the graph crate: the
113/// solver produces typed `SolverError` variants internally; the PyO3 boundary
114/// layer (`spectrafit-core`) and the rest of the workspace consume
115/// `CoreError`, so this conversion lets `?` flatten without losing the
116/// `Display` message.
117impl From<SolverError> for CoreError {
118    fn from(value: SolverError) -> Self {
119        match value {
120            // The solver-specific failure modes map to the dedicated
121            // `CoreError::Solver` variant so callers downstream can recognise
122            // "the solver ran but did not produce a usable answer" as distinct
123            // from a graph compilation problem.
124            SolverError::GlobalFailure(_)
125            | SolverError::IllConditioned { .. }
126            | SolverError::IrlsFailure(_) => CoreError::Solver(value.to_string()),
127            // Dispatch / setup-time failures (malformed graph input, VarPro
128            // capability mismatches, etc.) are evaluation-domain errors.
129            SolverError::Dispatch(_)
130            | SolverError::VarproNotSeparable
131            | SolverError::VarproExprEdgesUnsupported
132            | SolverError::VarproDatasetScopingUnsupported
133            | SolverError::MalformedTiedTarget(_)
134            | SolverError::TiedTargetNodeMissing(_)
135            | SolverError::TiedTargetParamMissing(_)
136            | SolverError::UnrecognisedSolver(_)
137            | SolverError::UnrecognisedWeightFn(_)
138            | SolverError::InvalidEta(_) => CoreError::Eval(value.to_string()),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn varpro_not_separable_maps_to_eval() {
149        let e: CoreError = SolverError::VarproNotSeparable.into();
150        match e {
151            CoreError::Eval(msg) => assert!(msg.contains("not separable")),
152            other => panic!("expected CoreError::Eval, got {other:?}"),
153        }
154    }
155
156    #[test]
157    fn global_failure_maps_to_solver() {
158        let e: CoreError = SolverError::GlobalFailure("nan".into()).into();
159        match e {
160            CoreError::Solver(msg) => assert!(msg.contains("nan")),
161            other => panic!("expected CoreError::Solver, got {other:?}"),
162        }
163    }
164
165    #[test]
166    fn ill_conditioned_maps_to_solver() {
167        let e: CoreError = SolverError::IllConditioned { kappa: 1e20 }.into();
168        match e {
169            CoreError::Solver(msg) => assert!(msg.contains("ill-conditioned")),
170            other => panic!("expected CoreError::Solver, got {other:?}"),
171        }
172    }
173
174    #[test]
175    fn malformed_tied_target_maps_to_eval() {
176        let e: CoreError = SolverError::MalformedTiedTarget("nodot".into()).into();
177        match e {
178            CoreError::Eval(msg) => assert!(msg.contains("malformed tied target")),
179            other => panic!("expected CoreError::Eval, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn unrecognised_solver_maps_to_eval() {
185        let e: CoreError = SolverError::UnrecognisedSolver("lmm".into()).into();
186        match e {
187            CoreError::Eval(msg) => {
188                assert!(msg.contains("lmm"));
189                assert!(msg.contains("expected one of"));
190            }
191            other => panic!("expected CoreError::Eval, got {other:?}"),
192        }
193    }
194
195    #[test]
196    fn unrecognised_weight_fn_maps_to_eval() {
197        let e: CoreError = SolverError::UnrecognisedWeightFn("buisquare".into()).into();
198        match e {
199            CoreError::Eval(msg) => {
200                assert!(msg.contains("buisquare"));
201                assert!(msg.contains("expected one of"));
202            }
203            other => panic!("expected CoreError::Eval, got {other:?}"),
204        }
205    }
206
207    #[test]
208    fn invalid_eta_maps_to_eval() {
209        let e: CoreError = SolverError::InvalidEta(0.5).into();
210        match e {
211            CoreError::Eval(msg) => {
212                assert!(msg.contains("eta"));
213                assert!(msg.contains("0.5"));
214            }
215            other => panic!("expected CoreError::Eval, got {other:?}"),
216        }
217    }
218}