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}