Skip to main content

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}