somatize_core/error.rs
1//! Error types for the Soma runtime.
2
3use thiserror::Error;
4
5/// The one error type the whole workspace returns.
6///
7/// A single enum rather than per-crate errors so `?` composes across every
8/// crate boundary without conversion layers. `#[non_exhaustive]` on purpose:
9/// most callers act on one or two variants — [`Suspended`](Self::Suspended),
10/// [`Pruned`](Self::Pruned) — and pass the rest along, so adding a variant
11/// should not break them.
12#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum SomaError {
15 /// `fit` was called without `y` on a filter that learns from labels.
16 #[error("filter requires labels (y) but none were provided")]
17 RequiresLabels,
18
19 /// The cache store failed to read, write, or resolve an entry.
20 #[error("cache error: {0}")]
21 Cache(String),
22
23 /// The compiler rejected the graph: a schema mismatch across an edge, a
24 /// loop or branch it cannot claim, a reference to nothing.
25 #[error("compilation error: {0}")]
26 Compilation(String),
27
28 /// A node failed while running. The workhorse variant: filter panics
29 /// (caught in `run_node`), step errors, and effect failures a step chose
30 /// not to absorb all surface here, named after the node they came from.
31 #[error("execution error at node `{node_id}`: {message}")]
32 Execution {
33 /// The node that failed.
34 node_id: String,
35 /// What went wrong.
36 message: String,
37 },
38
39 /// A pruner stopped the trial early. Closer to control flow than to a
40 /// fault: the study runner records the trial as pruned, not failed, and
41 /// the Python bindings surface it as its own exception type.
42 #[error("trial pruned at step {step}: {reason}")]
43 Pruned {
44 /// The intermediate-report step at which the pruner intervened.
45 step: usize,
46 /// Which rule fired, in the pruner's words.
47 reason: String,
48 },
49
50 /// The run stopped at `node_id`, waiting for something outside it.
51 ///
52 /// Not a failure: the work so far is journaled and the run continues
53 /// where it left off once the answer is supplied. It travels as an error
54 /// so that `?` unwinds the whole plan — a suspended run must not have
55 /// its later nodes execute — while callers that care can match on it.
56 /// `reason` stays typed. It used to be
57 /// `serde_json::to_string(&reason).unwrap_or("unknown")` — the shape a
58 /// caller needs in order to answer, flattened into a string that
59 /// nothing ever parsed back, which is why resuming was unreachable
60 /// from anywhere but Rust.
61 #[error("run `{run_id}` suspended at node `{node_id}` (turn {turn}): {}", reason.label())]
62 Suspended {
63 /// The run that stopped — the id to resume with.
64 run_id: String,
65 /// The node whose step suspended.
66 node_id: String,
67 /// The step's turn at the moment it suspended; resume replays the
68 /// journal up to here and re-polls with the answer.
69 turn: usize,
70 /// Boxed: `SomaError` is returned from nearly every function in
71 /// the workspace, and this is the only variant with a payload
72 /// worth more than a pointer.
73 reason: Box<crate::effect::SuspendReason>,
74 },
75
76 /// A value did not have the shape its consumer declared — raised both by
77 /// the compiler checking edges and at runtime when data arrives.
78 #[error("schema mismatch: expected {expected}, got {got}")]
79 SchemaMismatch {
80 /// What the consumer's schema demands.
81 expected: String,
82 /// What actually arrived.
83 got: String,
84 },
85
86 /// Something referenced a node id — an edge endpoint, a `Goto` target, a
87 /// spawn spec — that the graph does not contain.
88 #[error("node `{0}` not found in graph")]
89 NodeNotFound(String),
90
91 /// The graph's data edges form a cycle, so no execution order exists.
92 /// Iteration is expressed as a declared loop the compiler claims, never
93 /// by wiring a data edge back around.
94 #[error("cycle detected in graph")]
95 CycleDetected,
96
97 /// Encoding or decoding failed — canonical CBOR for identities and
98 /// journal keys, JSON for values, states, and the wire.
99 #[error("serialization error: {0}")]
100 Serialization(String),
101
102 /// A [`crate::store::DataStore`] backend failed to put, get, or move data.
103 #[error("data store error: {0}")]
104 DataStore(String),
105
106 /// An underlying filesystem error, converted via `?`.
107 #[error("io error: {0}")]
108 Io(#[from] std::io::Error),
109
110 /// An error that fits no other variant — mostly host code and bindings.
111 #[error("{0}")]
112 Other(String),
113}
114
115/// Shorthand for `std::result::Result` with [`SomaError`], used across the
116/// workspace.
117pub type Result<T> = std::result::Result<T, SomaError>;
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn error_display_messages() {
125 let err = SomaError::RequiresLabels;
126 assert_eq!(
127 err.to_string(),
128 "filter requires labels (y) but none were provided"
129 );
130
131 let err = SomaError::Execution {
132 node_id: "scaler_1".into(),
133 message: "dimension mismatch".into(),
134 };
135 assert_eq!(
136 err.to_string(),
137 "execution error at node `scaler_1`: dimension mismatch"
138 );
139
140 let err = SomaError::Pruned {
141 step: 5,
142 reason: "below median".into(),
143 };
144 assert_eq!(err.to_string(), "trial pruned at step 5: below median");
145 }
146
147 #[test]
148 fn result_type_alias_works() {
149 fn ok_fn() -> Result<i32> {
150 Ok(42)
151 }
152 fn err_fn() -> Result<i32> {
153 Err(SomaError::CycleDetected)
154 }
155 assert_eq!(ok_fn().unwrap(), 42);
156 assert!(err_fn().is_err());
157 }
158}