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}