Skip to main content

nir_rs/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Structured errors for `nir-rs`.
4//!
5//! Covers graph construction/validation, tensor shape checks, and HDF5 `.nir`
6//! I/O.
7
8use std::fmt;
9use thiserror::Error;
10
11/// Result type used across the crate.
12pub type Result<T> = std::result::Result<T, NirError>;
13
14/// Local convolution or pooling parameter invariant that failed.
15///
16/// Each variant is unambiguous from the node's own fields (no whole-graph
17/// shape inference). Returned inside [`NirError::InvalidNodeParameters`].
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[non_exhaustive]
20pub enum ParameterError {
21    /// Weight tensor rank does not match the convolution kind.
22    #[error("weight rank {found} is not {expected}")]
23    WeightRank {
24        /// Observed rank (`shape.len()`).
25        found: usize,
26        /// Required rank (3 for `Conv1d`, 4 for `Conv2d`).
27        expected: usize,
28    },
29    /// `groups` is not a positive count.
30    #[error("groups must be > 0, found {found}")]
31    Groups {
32        /// Observed `groups` value.
33        found: i64,
34    },
35    /// Output-channel count is not divisible by `groups`.
36    #[error("output channels {channels} are not divisible by groups {groups}")]
37    ChannelGroupDivisibility {
38        /// `weight` output-channel axis (`shape[0]`).
39        channels: usize,
40        /// Observed `groups` value.
41        groups: i64,
42    },
43    /// A stride, dilation, padding, or pooling window has the wrong length.
44    #[error("{field} arity {found} is not {expected}")]
45    ExtentArity {
46        /// Field name (`stride`, `dilation`, `padding`, `kernel_size`, …).
47        field: &'static str,
48        /// Human-readable allowed arity (`1`, `1 or 2`).
49        expected: &'static str,
50        /// Observed number of extents.
51        found: usize,
52    },
53    /// A pooling window tensor is not a scalar or a 1-D vector of extents.
54    #[error("{field} rank {found} is not 0 or 1")]
55    ExtentRank {
56        /// Field name (`kernel_size`, `stride`, `padding`).
57        field: &'static str,
58        /// Observed rank (`shape.len()`).
59        found: usize,
60    },
61    /// A pooling window tensor is not an integer payload.
62    #[error("{field} must contain i64 extents, found {dtype}")]
63    ExtentDType {
64        /// Field name (`kernel_size`, `stride`, `padding`).
65        field: &'static str,
66        /// Observed payload dtype label (`f32`, `f64`, `bool`).
67        dtype: &'static str,
68    },
69    /// A stride, dilation, or kernel extent is not strictly positive.
70    #[error("{field} extents must be strictly positive, found {value} at index {index}")]
71    ExtentPositive {
72        /// Field name (`stride`, `dilation`, `kernel_size`).
73        field: &'static str,
74        /// Index of the offending extent.
75        index: usize,
76        /// Observed value.
77        value: i64,
78    },
79    /// An explicit padding extent is negative.
80    #[error("{field} extents must be non-negative, found {value} at index {index}")]
81    ExtentNonNegative {
82        /// Field name (`padding`).
83        field: &'static str,
84        /// Index of the offending extent.
85        index: usize,
86        /// Observed value.
87        value: i64,
88    },
89    /// Bias element count does not match the convolution's output channels.
90    #[error("bias length {found} is incompatible with {expected} output channels")]
91    BiasLength {
92        /// `bias.numel()`.
93        found: usize,
94        /// Output-channel count from `weight.shape[0]`.
95        expected: usize,
96    },
97    /// Bias tensor is not rank-1.
98    #[error("bias rank {found} is not 1")]
99    BiasRank {
100        /// Observed rank (`shape.len()`).
101        found: usize,
102    },
103    /// A weight tensor axis extent is not strictly positive.
104    #[error("weight extents must be strictly positive, found 0 along axis {axis}")]
105    WeightExtentPositive {
106        /// Offending axis index.
107        axis: usize,
108    },
109}
110
111/// Structural collection bounded by a [`crate::io::ReadOptions`] count limit.
112///
113/// Distinct from the decoded-allocation budget (`max_bytes` /
114/// [`NirError::ReadLimitExceeded`]): these count nodes, edges, or nested
115/// graph groups rather than decoded payload bytes.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117#[non_exhaustive]
118pub enum ReadLimitResource {
119    /// Total nodes across the root graph and every nested `NIRGraph`.
120    Nodes,
121    /// Total edges across the root graph and every nested `NIRGraph`.
122    Edges,
123    /// Total `NIRGraph` groups decoded from the file, including the root.
124    NestedGraphs,
125}
126
127impl fmt::Display for ReadLimitResource {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(match self {
130            Self::Nodes => "nodes",
131            Self::Edges => "edges",
132            Self::NestedGraphs => "nested graphs",
133        })
134    }
135}
136
137/// Public error type for NIR operations.
138#[derive(Debug, Clone, PartialEq, Eq, Error)]
139#[non_exhaustive]
140pub enum NirError {
141    /// Feature not yet implemented.
142    ///
143    /// Returned by HDF5 I/O function stubs when the `hdf5` feature is not enabled.
144    #[error("not implemented: {0}")]
145    Unimplemented(&'static str),
146
147    /// HDF5 / wire `type` string is not a known NIR node.
148    #[error("unknown node type: {0}")]
149    UnknownNodeType(String),
150
151    /// A node name was inserted twice into the same graph.
152    #[error("duplicate node: {0}")]
153    DuplicateNode(String),
154
155    /// An edge or lookup referenced a node name that is not in the graph.
156    #[error("missing node: {0}")]
157    MissingNode(String),
158
159    /// The same directed edge `(src, dst)` appears more than once.
160    #[error("duplicate edge: ({0}, {1})")]
161    DuplicateEdge(String, String),
162
163    /// Structural or semantic problem with a graph that is not covered above.
164    #[error("invalid graph: {0}")]
165    InvalidGraph(String),
166
167    /// NIR file / graph version is not supported by this crate.
168    #[error("unsupported version: {0}")]
169    UnsupportedVersion(String),
170
171    /// An opt-in [`crate::io::VersionPolicy`] rejected this file's `/version`.
172    ///
173    /// Default [`crate::io::read`] is permissive and never produces this
174    /// variant. `observed` is [`None`] when the dataset was missing.
175    #[error(
176        "unsupported version: {} (policy: {policy})",
177        .observed.as_deref().unwrap_or("<absent>")
178    )]
179    IncompatibleVersion {
180        /// Observed `/version` string, or [`None`] when the dataset was absent.
181        observed: Option<String>,
182        /// Canonical [`crate::io::VersionPolicy`] description that rejected it.
183        policy: String,
184    },
185
186    /// A required wire field was absent when decoding a node or graph.
187    #[error("missing field: {0}")]
188    MissingField(String),
189
190    /// Tensor shape / data length mismatch or other tensor invariant failure.
191    #[error("invalid tensor: {0}")]
192    InvalidTensor(String),
193
194    /// Local convolution or pooling parameter invariant failed.
195    ///
196    /// Returned by [`crate::NirNode::validate_parameters`] and
197    /// [`crate::NirGraph::validate_parameters`]. HDF5 reads never emit this
198    /// variant; callers opt in after import. The default writer also does not
199    /// run parameter validation.
200    #[error("invalid parameters for {node_type} node {node}: {kind}")]
201    InvalidNodeParameters {
202        /// Graph node name, or a `/`-separated path through nested subgraphs.
203        ///
204        /// Isolated [`crate::NirNode::validate_parameters`] uses `"<node>"`.
205        node: String,
206        /// Wire type string (`Conv1d`, `SumPool2d`, …).
207        node_type: &'static str,
208        /// The invariant that failed.
209        kind: ParameterError,
210    },
211
212    /// A bounded read would exceed its decoded-allocation budget.
213    #[error(
214        "read allocation limit exceeded at {context}: limit {limit} bytes, used {used} bytes, requested {requested} bytes"
215    )]
216    ReadLimitExceeded {
217        /// Dataset or synthesized field being charged.
218        context: String,
219        /// Configured decoded-allocation limit in bytes.
220        limit: usize,
221        /// Bytes already charged by earlier allocations.
222        used: usize,
223        /// Bytes requested by the allocation that was rejected.
224        requested: usize,
225    },
226
227    /// A bounded read would exceed a node, edge, or nested-graph count budget.
228    #[error(
229        "read limit exceeded for {resource} at {context}: limit {limit}, used {used}, requested {requested}"
230    )]
231    ReadCountLimitExceeded {
232        /// Which collection budget was exhausted.
233        resource: ReadLimitResource,
234        /// Graph path where the charge was attempted.
235        context: String,
236        /// Configured count limit.
237        limit: usize,
238        /// Counts already charged by earlier collections.
239        used: usize,
240        /// Counts requested by the collection that was rejected.
241        requested: usize,
242    },
243
244    /// Filesystem or HDF5 library failure while reading or writing a `.nir` file.
245    ///
246    /// The underlying `hdf5::Error` is rendered into the message rather than
247    /// carried, so [`NirError`] stays `Clone + Eq`.
248    #[error("io error: {0}")]
249    Io(String),
250}
251
252/// Render an HDF5 library failure into [`NirError::Io`].
253///
254/// The message is flattened into a `String` because `hdf5::Error` is neither
255/// `Clone` nor `Eq`, and this enum is both.
256#[cfg(feature = "hdf5")]
257impl From<hdf5::Error> for NirError {
258    fn from(err: hdf5::Error) -> Self {
259        Self::Io(err.to_string())
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn unimplemented_display() {
269        let err = NirError::Unimplemented("hdf5 read");
270        assert_eq!(err.to_string(), "not implemented: hdf5 read");
271    }
272
273    #[test]
274    fn unknown_node_type_display() {
275        let err = NirError::UnknownNodeType("CurrLIF".into());
276        assert_eq!(err.to_string(), "unknown node type: CurrLIF");
277    }
278
279    #[test]
280    fn duplicate_node_display() {
281        let err = NirError::DuplicateNode("lif".into());
282        assert_eq!(err.to_string(), "duplicate node: lif");
283    }
284
285    #[test]
286    fn missing_node_display() {
287        let err = NirError::MissingNode("missing".into());
288        assert_eq!(err.to_string(), "missing node: missing");
289    }
290
291    #[test]
292    fn duplicate_edge_display() {
293        let err = NirError::DuplicateEdge("a".into(), "b".into());
294        assert_eq!(err.to_string(), "duplicate edge: (a, b)");
295    }
296
297    #[test]
298    fn invalid_graph_display() {
299        let err = NirError::InvalidGraph("empty subgraph".into());
300        assert_eq!(err.to_string(), "invalid graph: empty subgraph");
301    }
302
303    #[test]
304    fn unsupported_version_display() {
305        let err = NirError::UnsupportedVersion("99.0".into());
306        assert_eq!(err.to_string(), "unsupported version: 99.0");
307    }
308
309    #[test]
310    fn incompatible_version_display_includes_observed_and_policy() {
311        let present = NirError::IncompatibleVersion {
312            observed: Some("99.0.0".into()),
313            policy: "compatible-major majors=[0, 1]".into(),
314        };
315        assert_eq!(
316            present.to_string(),
317            "unsupported version: 99.0.0 (policy: compatible-major majors=[0, 1])"
318        );
319        let missing = NirError::IncompatibleVersion {
320            observed: None,
321            policy: "require-present".into(),
322        };
323        assert_eq!(
324            missing.to_string(),
325            "unsupported version: <absent> (policy: require-present)"
326        );
327    }
328
329    #[test]
330    fn missing_field_display() {
331        let err = NirError::MissingField("weight".into());
332        assert_eq!(err.to_string(), "missing field: weight");
333    }
334
335    #[test]
336    fn invalid_tensor_display() {
337        let err = NirError::InvalidTensor("shape product 4 != data len 3".into());
338        assert_eq!(
339            err.to_string(),
340            "invalid tensor: shape product 4 != data len 3"
341        );
342    }
343
344    #[test]
345    fn invalid_node_parameters_display() {
346        let err = NirError::InvalidNodeParameters {
347            node: "conv".into(),
348            node_type: "Conv1d",
349            kind: ParameterError::WeightRank {
350                found: 2,
351                expected: 3,
352            },
353        };
354        assert_eq!(
355            err.to_string(),
356            "invalid parameters for Conv1d node conv: weight rank 2 is not 3"
357        );
358    }
359
360    #[test]
361    fn parameter_error_displays() {
362        let cases = [
363            (
364                ParameterError::Groups { found: 0 },
365                "groups must be > 0, found 0",
366            ),
367            (
368                ParameterError::ChannelGroupDivisibility {
369                    channels: 3,
370                    groups: 2,
371                },
372                "output channels 3 are not divisible by groups 2",
373            ),
374            (
375                ParameterError::ExtentArity {
376                    field: "stride",
377                    expected: "1",
378                    found: 2,
379                },
380                "stride arity 2 is not 1",
381            ),
382            (
383                ParameterError::ExtentRank {
384                    field: "kernel_size",
385                    found: 2,
386                },
387                "kernel_size rank 2 is not 0 or 1",
388            ),
389            (
390                ParameterError::ExtentDType {
391                    field: "padding",
392                    dtype: "f32",
393                },
394                "padding must contain i64 extents, found f32",
395            ),
396            (
397                ParameterError::ExtentPositive {
398                    field: "dilation",
399                    index: 0,
400                    value: 0,
401                },
402                "dilation extents must be strictly positive, found 0 at index 0",
403            ),
404            (
405                ParameterError::ExtentNonNegative {
406                    field: "padding",
407                    index: 1,
408                    value: -1,
409                },
410                "padding extents must be non-negative, found -1 at index 1",
411            ),
412            (
413                ParameterError::BiasLength {
414                    found: 1,
415                    expected: 2,
416                },
417                "bias length 1 is incompatible with 2 output channels",
418            ),
419        ];
420        for (err, expected) in cases {
421            assert_eq!(err.to_string(), expected);
422        }
423    }
424
425    #[test]
426    fn read_limit_display() {
427        let err = NirError::ReadLimitExceeded {
428            context: "lif.tau".into(),
429            limit: 1024,
430            used: 768,
431            requested: 512,
432        };
433        assert_eq!(
434            err.to_string(),
435            "read allocation limit exceeded at lif.tau: limit 1024 bytes, used 768 bytes, requested 512 bytes"
436        );
437    }
438
439    #[test]
440    fn read_count_limit_display_identifies_resource_and_path() {
441        let err = NirError::ReadCountLimitExceeded {
442            resource: ReadLimitResource::Nodes,
443            context: "/node/nodes".into(),
444            limit: 3,
445            used: 2,
446            requested: 2,
447        };
448        assert_eq!(
449            err.to_string(),
450            "read limit exceeded for nodes at /node/nodes: limit 3, used 2, requested 2"
451        );
452        assert_eq!(ReadLimitResource::Edges.to_string(), "edges");
453        assert_eq!(ReadLimitResource::NestedGraphs.to_string(), "nested graphs");
454    }
455
456    #[test]
457    fn io_display() {
458        let err = NirError::Io("unable to open file: model.nir".into());
459        assert_eq!(err.to_string(), "io error: unable to open file: model.nir");
460    }
461
462    #[test]
463    fn error_trait_implemented() {
464        let err: Box<dyn std::error::Error> = Box::new(NirError::Unimplemented("x"));
465        assert!(err.to_string().contains("not implemented"));
466    }
467}