Skip to main content

spectrafit_graph/
error.rs

1//! Graph compilation and execution errors.
2//!
3//! [`GraphError`] is defined as the typed error shape we INTEND to use at
4//! API boundaries. Today, public functions still return
5//! `Result<_, CoreError>` — the conversion remains a follow-up, not yet
6//! done. New code that needs structured graph error reporting
7//! should construct GraphError variants now; legacy `CoreError::Eval(String)`
8//! call sites are migrated incrementally.
9//!
10//! ## Invariant vs. boundary-crossing sites
11//!
12//! Not every `unwrap()` in this crate is a bug.  Two categories exist:
13//!
14//! - **Boundary-crossing**: an `unwrap()` that can be triggered by external
15//!   input (malformed schema, unknown model key, missing parameter, etc.).
16//!   These are converted to `Result<_, GraphError>` variants here.
17//! - **Invariant**: an `unwrap()` that is protected by a prior structural
18//!   guarantee (e.g. "we just inserted this key, so `.get()` must return
19//!   `Some`").  These are kept but annotated with an `// INVARIANT:` comment
20//!   explaining the guarantee that rules out the failure branch.
21
22use spectrafit_types::CoreError;
23use thiserror::Error;
24
25/// Errors produced by the graph compiler and executor.
26#[derive(Debug, Error)]
27pub enum GraphError {
28    /// A node referenced by ID was not found in the compiled graph.
29    ///
30    /// Triggered when user-supplied `params_flat` references a node that does
31    /// not exist in the graph.
32    #[error("unknown node id: '{0}'")]
33    UnknownNode(String),
34
35    /// A cycle was detected in the `expr_edge` dependency graph.
36    ///
37    /// Triggered during compilation when two or more `expr_edges` form a
38    /// circular dependency (e.g. `a → b → a`).
39    #[error("cycle detected in expr_edge dependency graph at target '{0}'")]
40    Cycle(String),
41
42    /// An expression string failed to lex or parse.
43    ///
44    /// Triggered when an `expr_edge.expression` contains syntax that the
45    /// restricted grammar does not support (unknown character, unbalanced
46    /// parentheses, trailing tokens, etc.).
47    #[error("malformed expression: {0}")]
48    MalformedExpression(String),
49
50    /// Two or more `expr_edges` point to the same target parameter.
51    ///
52    /// Each parameter may be the target of at most one `expr_edge`.
53    #[error("duplicate expr_edge target: '{0}'")]
54    DuplicateExprTarget(String),
55
56    /// A required model parameter is missing from the node's `parameters` map.
57    ///
58    /// Triggered at compile time when the spec omits a parameter required by
59    /// the model kernel.
60    #[error("node '{node}' is missing required parameter '{param}'")]
61    MissingParameter {
62        /// Node identifier.
63        node: String,
64        /// Required parameter name that was absent.
65        param: String,
66    },
67
68    /// Two nodes in the graph share the same `id`.
69    ///
70    /// Node IDs key the free-column layout and the per-node component map;
71    /// a duplicate ID would silently corrupt both.
72    #[error("duplicate node id '{0}': node ids must be unique within a graph")]
73    DuplicateNodeId(String),
74
75    /// The `model_type` string was not recognised by the model registry.
76    #[error("unknown model type: '{0}'")]
77    UnknownModelType(String),
78
79    /// An expression evaluation failed (e.g. division by zero, missing key).
80    #[error("expression evaluation failed: {0}")]
81    EvalFailure(String),
82
83    /// The `x` buffer length is not a multiple of the graph's `n_dims`.
84    #[error("x buffer length {x_len} is not a multiple of n_dims {n_dims}")]
85    XBufferStrideMismatch {
86        /// Length of the supplied `x` slice.
87        x_len: usize,
88        /// Expected stride (number of coordinate components per point).
89        n_dims: usize,
90    },
91
92    /// The graph mixes nodes with different coordinate dimensionalities.
93    ///
94    /// All nodes in a graph must share the same `n_dims`.
95    #[error(
96        "dimensionality mismatch: node '{first}' has n_dims={first_nd} \
97         but node '{second}' has n_dims={second_nd}"
98    )]
99    DimensionalityMismatch {
100        /// First (reference) node id.
101        first: String,
102        /// `n_dims` of the first node.
103        first_nd: usize,
104        /// Second (conflicting) node id.
105        second: String,
106        /// `n_dims` of the second node.
107        second_nd: usize,
108    },
109
110    /// A `params_flat` entry that is required for evaluation is missing.
111    ///
112    /// Triggered when the caller's `params_flat` map does not contain a key
113    /// for a parameter that the model requires.
114    #[error("missing parameter key '{0}' in params_flat")]
115    MissingParamKey(String),
116
117    /// Division by zero occurred while evaluating a tied expression.
118    #[error("expr division by zero")]
119    DivisionByZero,
120
121    /// An expression's input was empty (no tokens after lex).
122    #[error("empty expression")]
123    EmptyExpression,
124
125    /// A `dataset_index` on a node points past the recorded `dataset_offsets`.
126    ///
127    /// Triggered when a node carries `dataset_index = Some(i)` but the solver
128    /// has filled `dataset_offsets` for fewer than `i + 1` datasets — indexing
129    /// would otherwise overrun `dataset_offsets`.
130    #[error(
131        "node '{node}' has dataset_index {dataset_index} but only {n_datasets} \
132         dataset(s) are recorded (valid indices 0..{n_datasets})"
133    )]
134    DatasetIndexOutOfRange {
135        /// Node identifier.
136        node: String,
137        /// Out-of-range index value.
138        dataset_index: usize,
139        /// Number of recorded datasets (valid indices are `0..n_datasets`).
140        n_datasets: usize,
141    },
142
143    /// A residual/observation/sigma buffer length disagreed with the point count.
144    #[error("residual, observation, sigma lengths must match the number of points")]
145    OutputBufferShape,
146
147    /// A reusable output buffer's length did not match the predicted point count.
148    #[error("output buffer length {actual} does not match number of points {expected}")]
149    OutputBufferLength {
150        /// Length of the supplied buffer.
151        actual: usize,
152        /// Required length (= number of points).
153        expected: usize,
154    },
155}
156
157/// Map [`GraphError`] to the workspace-wide [`CoreError`].
158///
159/// `GraphError` is the structured shape the graph crate produces internally;
160/// `CoreError::Eval(String)` is the legacy stringly-typed boundary the rest of
161/// the workspace consumes. This conversion lets callers use `?` to flatten a
162/// graph-layer failure into the boundary error type without losing the
163/// `Display` message.
164impl From<GraphError> for CoreError {
165    fn from(value: GraphError) -> Self {
166        CoreError::Eval(value.to_string())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn graph_error_converts_into_core_error_eval() {
176        let g = GraphError::UnknownModelType("nonsuch".to_string());
177        let c: CoreError = g.into();
178        match c {
179            CoreError::Eval(msg) => {
180                assert!(msg.contains("unknown model type"));
181                assert!(msg.contains("nonsuch"));
182            }
183            other => panic!("expected CoreError::Eval, got {other:?}"),
184        }
185    }
186
187    #[test]
188    fn graph_error_division_by_zero_renders() {
189        let g = GraphError::DivisionByZero;
190        assert_eq!(format!("{g}"), "expr division by zero");
191    }
192
193    #[test]
194    fn graph_error_dataset_index_renders() {
195        let g = GraphError::DatasetIndexOutOfRange {
196            node: "bg".to_string(),
197            dataset_index: 5,
198            n_datasets: 2,
199        };
200        let s = format!("{g}");
201        assert!(s.contains("'bg'"));
202        assert!(s.contains("dataset_index 5"));
203        assert!(s.contains("only 2 dataset"));
204    }
205}