Skip to main content

oximedia_gpu/
compute_graph.rs

1//! GPU compute graph — a typed, topologically-ordered execution planner.
2//!
3//! Models the task graph used internally by modern GPU driver stacks (e.g.
4//! DirectX 12 command lists, Vulkan render graphs, WebGPU compute passes).
5//!
6//! # Node types
7//!
8//! | Variant | Meaning |
9//! |---------|---------|
10//! | [`NodeKind::Kernel`] | Dispatch a compute shader / kernel. |
11//! | [`NodeKind::Copy`] | Transfer data between buffers (DMA-style). |
12//! | [`NodeKind::Barrier`] | Memory / execution barrier between stages. |
13//!
14//! # Workflow
15//!
16//! 1. Create a [`ComputeGraph`].
17//! 2. Add nodes via [`ComputeGraph::add_node`].
18//! 3. Add resource bindings via [`ComputeGraph::bind_resource`].
19//! 4. Add edges via [`ComputeGraph::add_edge`].
20//! 5. Call [`ComputeGraph::execution_order`] to obtain a valid ordering.
21//! 6. Optionally call [`ComputeGraph::validate`] to check for binding issues.
22
23use std::collections::{BTreeMap, BTreeSet, VecDeque};
24use thiserror::Error;
25
26// ─── Error ────────────────────────────────────────────────────────────────────
27
28/// Errors produced by the compute graph.
29#[derive(Debug, Clone, PartialEq, Error)]
30pub enum GraphError {
31    /// A node with this ID does not exist.
32    #[error("Node not found: {0}")]
33    NodeNotFound(u32),
34    /// A node with this ID has already been added.
35    #[error("Duplicate node ID: {0}")]
36    DuplicateNode(u32),
37    /// Adding this edge would introduce a cycle.
38    #[error("Edge from node {from} to node {to} would create a cycle")]
39    CyclicEdge { from: u32, to: u32 },
40    /// The graph contains a cycle (defensive check during ordering).
41    #[error("Compute graph contains a cycle; cannot determine execution order")]
42    CycleDetected,
43    /// A required resource binding is missing.
44    #[error("Node {node_id} is missing required resource binding '{resource}'")]
45    MissingBinding { node_id: u32, resource: String },
46    /// A resource is bound to an incompatible node type.
47    #[error("Resource '{resource}' cannot be bound to a {node_kind:?} node")]
48    IncompatibleBinding {
49        resource: String,
50        node_kind: NodeKind,
51    },
52}
53
54// ─── NodeKind ─────────────────────────────────────────────────────────────────
55
56/// The functional category of a compute graph node.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum NodeKind {
59    /// A compute kernel dispatch.
60    Kernel {
61        /// Shader entry-point label.
62        entry_point: String,
63        /// Number of thread groups along X, Y, Z axes.
64        dispatch: [u32; 3],
65    },
66    /// A buffer-to-buffer copy operation.
67    Copy {
68        /// Identifier of the source buffer.
69        src_buffer: u32,
70        /// Identifier of the destination buffer.
71        dst_buffer: u32,
72        /// Number of bytes to copy.
73        byte_count: usize,
74    },
75    /// An execution or memory barrier.
76    Barrier {
77        /// Stage that must complete before the barrier.
78        src_stage: PipelineStageFlags,
79        /// Stage that must wait after the barrier.
80        dst_stage: PipelineStageFlags,
81    },
82}
83
84// ─── PipelineStageFlags ───────────────────────────────────────────────────────
85
86/// Bit flags representing pipeline stages for barrier nodes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub struct PipelineStageFlags(pub u32);
89
90impl PipelineStageFlags {
91    /// No stage.
92    pub const NONE: Self = Self(0);
93    /// Compute shader stage.
94    pub const COMPUTE_SHADER: Self = Self(1 << 0);
95    /// Transfer / DMA stage.
96    pub const TRANSFER: Self = Self(1 << 1);
97    /// Host-side (CPU) read/write stage.
98    pub const HOST: Self = Self(1 << 2);
99    /// All stages (convenience sentinel).
100    pub const ALL: Self = Self(0xFFFF_FFFF);
101
102    /// Return `true` if `other` is a subset of `self`.
103    #[must_use]
104    pub fn contains(self, other: Self) -> bool {
105        (self.0 & other.0) == other.0
106    }
107
108    /// Bitwise OR of two flag sets.
109    #[must_use]
110    pub fn union(self, other: Self) -> Self {
111        Self(self.0 | other.0)
112    }
113}
114
115// ─── ResourceBinding ──────────────────────────────────────────────────────────
116
117/// A named resource bound to a specific node.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ResourceBinding {
120    /// Logical binding name (e.g. `"input_buffer"`, `"output_texture"`).
121    pub name: String,
122    /// Buffer or texture ID this binding points to.
123    pub resource_id: u32,
124    /// Access mode.
125    pub access: ResourceAccess,
126}
127
128/// Read / write access mode for a resource binding.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum ResourceAccess {
131    /// The node only reads from this resource.
132    ReadOnly,
133    /// The node only writes to this resource.
134    WriteOnly,
135    /// The node both reads and writes to this resource.
136    ReadWrite,
137}
138
139// ─── GraphNode ────────────────────────────────────────────────────────────────
140
141/// A single node in the compute graph.
142#[derive(Debug, Clone)]
143pub struct GraphNode {
144    /// Unique ID within the graph.
145    pub id: u32,
146    /// Human-readable label for profiling / debugging.
147    pub label: String,
148    /// Functional type of this node.
149    pub kind: NodeKind,
150    /// Resource bindings declared for this node.
151    pub bindings: Vec<ResourceBinding>,
152}
153
154impl GraphNode {
155    /// Construct a new `GraphNode` with no bindings.
156    #[must_use]
157    pub fn new(id: u32, label: impl Into<String>, kind: NodeKind) -> Self {
158        Self {
159            id,
160            label: label.into(),
161            kind,
162            bindings: Vec::new(),
163        }
164    }
165}
166
167// ─── ExecutionPlan ────────────────────────────────────────────────────────────
168
169/// The result of topological ordering: an ordered list of node IDs.
170#[derive(Debug, Clone)]
171pub struct ExecutionPlan {
172    /// Node IDs in valid execution order (dependencies first).
173    pub order: Vec<u32>,
174    /// Estimated total dispatch work (sum of kernel thread groups).
175    pub total_dispatch_groups: u64,
176    /// Number of barrier nodes in the graph.
177    pub barrier_count: usize,
178    /// Number of copy nodes in the graph.
179    pub copy_count: usize,
180}
181
182// ─── ComputeGraph ─────────────────────────────────────────────────────────────
183
184/// Directed acyclic graph of compute nodes with resource binding management.
185pub struct ComputeGraph {
186    /// All nodes, keyed by ID.
187    nodes: BTreeMap<u32, GraphNode>,
188    /// Forward adjacency: `adj[a]` = set of nodes that must run *after* `a`.
189    adj: BTreeMap<u32, BTreeSet<u32>>,
190    /// Reverse adjacency: `radj[b]` = set of nodes that `b` depends on.
191    radj: BTreeMap<u32, BTreeSet<u32>>,
192}
193
194impl ComputeGraph {
195    /// Create an empty compute graph.
196    #[must_use]
197    pub fn new() -> Self {
198        Self {
199            nodes: BTreeMap::new(),
200            adj: BTreeMap::new(),
201            radj: BTreeMap::new(),
202        }
203    }
204
205    /// Add a node to the graph.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`GraphError::DuplicateNode`] if a node with the same ID already
210    /// exists.
211    pub fn add_node(&mut self, node: GraphNode) -> Result<(), GraphError> {
212        if self.nodes.contains_key(&node.id) {
213            return Err(GraphError::DuplicateNode(node.id));
214        }
215        let id = node.id;
216        self.nodes.insert(id, node);
217        self.adj.entry(id).or_default();
218        self.radj.entry(id).or_default();
219        Ok(())
220    }
221
222    /// Attach a resource binding to an existing node.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`GraphError::NodeNotFound`] if the node does not exist.
227    /// Returns [`GraphError::IncompatibleBinding`] if the resource type is not
228    /// appropriate for the node kind (e.g. binding a buffer to a `Barrier`).
229    pub fn bind_resource(
230        &mut self,
231        node_id: u32,
232        binding: ResourceBinding,
233    ) -> Result<(), GraphError> {
234        let node = self
235            .nodes
236            .get_mut(&node_id)
237            .ok_or(GraphError::NodeNotFound(node_id))?;
238        // Barrier nodes do not consume buffers.
239        if matches!(node.kind, NodeKind::Barrier { .. }) {
240            return Err(GraphError::IncompatibleBinding {
241                resource: binding.name,
242                node_kind: NodeKind::Barrier {
243                    src_stage: PipelineStageFlags::NONE,
244                    dst_stage: PipelineStageFlags::NONE,
245                },
246            });
247        }
248        node.bindings.push(binding);
249        Ok(())
250    }
251
252    /// Add a directed edge: `from` → `to` (node `from` must execute before `to`).
253    ///
254    /// # Errors
255    ///
256    /// * [`GraphError::NodeNotFound`] if either node is missing.
257    /// * [`GraphError::CyclicEdge`] if the edge would introduce a cycle.
258    pub fn add_edge(&mut self, from: u32, to: u32) -> Result<(), GraphError> {
259        if !self.nodes.contains_key(&from) {
260            return Err(GraphError::NodeNotFound(from));
261        }
262        if !self.nodes.contains_key(&to) {
263            return Err(GraphError::NodeNotFound(to));
264        }
265        // Cycle check: can `from` already be reached *from* `to`?
266        if self.is_reachable(to, from) {
267            return Err(GraphError::CyclicEdge { from, to });
268        }
269        self.adj.entry(from).or_default().insert(to);
270        self.radj.entry(to).or_default().insert(from);
271        Ok(())
272    }
273
274    /// Compute a valid [`ExecutionPlan`] via topological ordering (Kahn's
275    /// algorithm with deterministic tie-breaking by node ID).
276    ///
277    /// # Errors
278    ///
279    /// Returns [`GraphError::CycleDetected`] if the graph contains a cycle
280    /// (defensive; the `add_edge` invariant should prevent this).
281    pub fn execution_order(&self) -> Result<ExecutionPlan, GraphError> {
282        let mut in_degree: BTreeMap<u32, usize> = self
283            .nodes
284            .keys()
285            .map(|&id| (id, self.radj[&id].len()))
286            .collect();
287
288        let mut ready: BTreeSet<u32> = in_degree
289            .iter()
290            .filter_map(|(&id, &deg)| if deg == 0 { Some(id) } else { None })
291            .collect();
292
293        let mut order = Vec::with_capacity(self.nodes.len());
294
295        while let Some(&next) = ready.iter().next() {
296            ready.remove(&next);
297            order.push(next);
298            for &successor in self
299                .adj
300                .get(&next)
301                .map_or(&BTreeSet::new() as &BTreeSet<u32>, |s| s)
302            {
303                let deg = in_degree.entry(successor).or_insert(0);
304                *deg = deg.saturating_sub(1);
305                if *deg == 0 {
306                    ready.insert(successor);
307                }
308            }
309        }
310
311        if order.len() != self.nodes.len() {
312            return Err(GraphError::CycleDetected);
313        }
314
315        // Build plan metrics.
316        let mut total_dispatch_groups: u64 = 0;
317        let mut barrier_count = 0usize;
318        let mut copy_count = 0usize;
319
320        for &id in &order {
321            if let Some(node) = self.nodes.get(&id) {
322                match &node.kind {
323                    NodeKind::Kernel { dispatch, .. } => {
324                        total_dispatch_groups +=
325                            dispatch.iter().map(|&d| u64::from(d)).product::<u64>();
326                    }
327                    NodeKind::Copy { .. } => copy_count += 1,
328                    NodeKind::Barrier { .. } => barrier_count += 1,
329                }
330            }
331        }
332
333        Ok(ExecutionPlan {
334            order,
335            total_dispatch_groups,
336            barrier_count,
337            copy_count,
338        })
339    }
340
341    /// Validate that every `Kernel` and `Copy` node has at least one resource
342    /// binding and that no required named bindings are missing.
343    ///
344    /// Barrier nodes are not checked (they do not use resource bindings).
345    ///
346    /// # Errors
347    ///
348    /// Returns the first [`GraphError::MissingBinding`] encountered.
349    pub fn validate(&self) -> Result<(), GraphError> {
350        for node in self.nodes.values() {
351            match &node.kind {
352                NodeKind::Kernel { .. } | NodeKind::Copy { .. } => {
353                    if node.bindings.is_empty() {
354                        return Err(GraphError::MissingBinding {
355                            node_id: node.id,
356                            resource: "<any>".to_string(),
357                        });
358                    }
359                }
360                NodeKind::Barrier { .. } => {} // no bindings required
361            }
362        }
363        Ok(())
364    }
365
366    /// Number of nodes in the graph.
367    #[must_use]
368    pub fn node_count(&self) -> usize {
369        self.nodes.len()
370    }
371
372    /// Number of directed edges in the graph.
373    #[must_use]
374    pub fn edge_count(&self) -> usize {
375        self.adj.values().map(|s| s.len()).sum()
376    }
377
378    /// Retrieve a node by ID.
379    #[must_use]
380    pub fn node(&self, id: u32) -> Option<&GraphNode> {
381        self.nodes.get(&id)
382    }
383
384    /// Return the IDs of all direct predecessors (nodes that must run before
385    /// `node_id`).
386    ///
387    /// # Errors
388    ///
389    /// Returns [`GraphError::NodeNotFound`] if the ID is not registered.
390    pub fn predecessors(&self, node_id: u32) -> Result<Vec<u32>, GraphError> {
391        if !self.nodes.contains_key(&node_id) {
392            return Err(GraphError::NodeNotFound(node_id));
393        }
394        Ok(self
395            .radj
396            .get(&node_id)
397            .map_or(vec![], |s| s.iter().copied().collect()))
398    }
399
400    /// Return the IDs of all direct successors (nodes that must run after
401    /// `node_id`).
402    ///
403    /// # Errors
404    ///
405    /// Returns [`GraphError::NodeNotFound`] if the ID is not registered.
406    pub fn successors(&self, node_id: u32) -> Result<Vec<u32>, GraphError> {
407        if !self.nodes.contains_key(&node_id) {
408            return Err(GraphError::NodeNotFound(node_id));
409        }
410        Ok(self
411            .adj
412            .get(&node_id)
413            .map_or(vec![], |s| s.iter().copied().collect()))
414    }
415
416    // ── Private helpers ───────────────────────────────────────────────────────
417
418    /// BFS reachability following *forward* edges.
419    fn is_reachable(&self, start: u32, target: u32) -> bool {
420        if start == target {
421            return true;
422        }
423        let mut visited = BTreeSet::new();
424        let mut queue = VecDeque::new();
425        queue.push_back(start);
426        while let Some(cur) = queue.pop_front() {
427            if visited.contains(&cur) {
428                continue;
429            }
430            visited.insert(cur);
431            if let Some(succs) = self.adj.get(&cur) {
432                for &s in succs {
433                    if s == target {
434                        return true;
435                    }
436                    queue.push_back(s);
437                }
438            }
439        }
440        false
441    }
442}
443
444impl Default for ComputeGraph {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450// ─── Tests ───────────────────────────────────────────────────────────────────
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn kernel_node(id: u32, dispatch: [u32; 3]) -> GraphNode {
457        GraphNode::new(
458            id,
459            format!("kernel_{id}"),
460            NodeKind::Kernel {
461                entry_point: format!("main_{id}"),
462                dispatch,
463            },
464        )
465    }
466
467    fn copy_node(id: u32, src: u32, dst: u32, bytes: usize) -> GraphNode {
468        GraphNode::new(
469            id,
470            format!("copy_{id}"),
471            NodeKind::Copy {
472                src_buffer: src,
473                dst_buffer: dst,
474                byte_count: bytes,
475            },
476        )
477    }
478
479    fn barrier_node(id: u32) -> GraphNode {
480        GraphNode::new(
481            id,
482            format!("barrier_{id}"),
483            NodeKind::Barrier {
484                src_stage: PipelineStageFlags::COMPUTE_SHADER,
485                dst_stage: PipelineStageFlags::TRANSFER,
486            },
487        )
488    }
489
490    fn simple_binding(name: &str, resource_id: u32) -> ResourceBinding {
491        ResourceBinding {
492            name: name.to_string(),
493            resource_id,
494            access: ResourceAccess::ReadWrite,
495        }
496    }
497
498    // ── PipelineStageFlags ────────────────────────────────────────────────────
499
500    #[test]
501    fn test_pipeline_stage_contains() {
502        let combined = PipelineStageFlags::COMPUTE_SHADER.union(PipelineStageFlags::TRANSFER);
503        assert!(combined.contains(PipelineStageFlags::COMPUTE_SHADER));
504        assert!(combined.contains(PipelineStageFlags::TRANSFER));
505        assert!(!combined.contains(PipelineStageFlags::HOST));
506    }
507
508    #[test]
509    fn test_pipeline_stage_all_contains_any() {
510        assert!(PipelineStageFlags::ALL.contains(PipelineStageFlags::COMPUTE_SHADER));
511        assert!(PipelineStageFlags::ALL.contains(PipelineStageFlags::HOST));
512    }
513
514    // ── ComputeGraph – construction ───────────────────────────────────────────
515
516    #[test]
517    fn test_add_node_and_count() -> Result<(), GraphError> {
518        let mut g = ComputeGraph::new();
519        g.add_node(kernel_node(1, [4, 1, 1]))?;
520        g.add_node(barrier_node(2))?;
521        assert_eq!(g.node_count(), 2);
522        Ok(())
523    }
524
525    #[test]
526    fn test_add_duplicate_node_error() -> Result<(), GraphError> {
527        let mut g = ComputeGraph::new();
528        g.add_node(kernel_node(1, [1, 1, 1]))?;
529        let err = g.add_node(kernel_node(1, [2, 2, 2]));
530        assert!(matches!(err, Err(GraphError::DuplicateNode(1))));
531        Ok(())
532    }
533
534    #[test]
535    fn test_add_edge_increments_count() -> Result<(), GraphError> {
536        let mut g = ComputeGraph::new();
537        g.add_node(kernel_node(1, [1, 1, 1]))?;
538        g.add_node(kernel_node(2, [1, 1, 1]))?;
539        g.add_edge(1, 2)?;
540        assert_eq!(g.edge_count(), 1);
541        Ok(())
542    }
543
544    #[test]
545    fn test_add_edge_unknown_node_error() -> Result<(), GraphError> {
546        let mut g = ComputeGraph::new();
547        g.add_node(kernel_node(1, [1, 1, 1]))?;
548        assert!(matches!(
549            g.add_edge(1, 99),
550            Err(GraphError::NodeNotFound(99))
551        ));
552        Ok(())
553    }
554
555    #[test]
556    fn test_add_cyclic_edge_error() -> Result<(), GraphError> {
557        let mut g = ComputeGraph::new();
558        g.add_node(kernel_node(1, [1, 1, 1]))?;
559        g.add_node(kernel_node(2, [1, 1, 1]))?;
560        g.add_edge(1, 2)?;
561        let err = g.add_edge(2, 1);
562        assert!(matches!(
563            err,
564            Err(GraphError::CyclicEdge { from: 2, to: 1 })
565        ));
566        Ok(())
567    }
568
569    // ── execution_order ───────────────────────────────────────────────────────
570
571    #[test]
572    fn test_execution_order_single_node() -> Result<(), GraphError> {
573        let mut g = ComputeGraph::new();
574        g.add_node(kernel_node(5, [8, 1, 1]))?;
575        let plan = g.execution_order()?;
576        assert_eq!(plan.order, vec![5]);
577        assert_eq!(plan.total_dispatch_groups, 8);
578        Ok(())
579    }
580
581    #[test]
582    fn test_execution_order_linear_chain() -> Result<(), GraphError> {
583        let mut g = ComputeGraph::new();
584        for id in [1, 2, 3] {
585            g.add_node(kernel_node(id, [2, 1, 1]))?;
586        }
587        g.add_edge(1, 2)?;
588        g.add_edge(2, 3)?;
589        let plan = g.execution_order()?;
590        assert_eq!(plan.order, vec![1, 2, 3]);
591        assert_eq!(plan.total_dispatch_groups, 6);
592        Ok(())
593    }
594
595    #[test]
596    fn test_execution_order_with_barrier_and_copy() -> Result<(), GraphError> {
597        // kernel → barrier → copy
598        let mut g = ComputeGraph::new();
599        g.add_node(kernel_node(1, [4, 4, 1]))?;
600        g.add_node(barrier_node(2))?;
601        g.add_node(copy_node(3, 0, 1, 1024))?;
602        g.add_edge(1, 2)?;
603        g.add_edge(2, 3)?;
604        let plan = g.execution_order()?;
605        assert_eq!(plan.order, vec![1, 2, 3]);
606        assert_eq!(plan.barrier_count, 1);
607        assert_eq!(plan.copy_count, 1);
608        assert_eq!(plan.total_dispatch_groups, 16);
609        Ok(())
610    }
611
612    #[test]
613    fn test_execution_order_independent_nodes_sorted_by_id() -> Result<(), GraphError> {
614        let mut g = ComputeGraph::new();
615        for id in [5, 3, 1] {
616            g.add_node(kernel_node(id, [1, 1, 1]))?;
617        }
618        let plan = g.execution_order()?;
619        assert_eq!(plan.order, vec![1, 3, 5]);
620        Ok(())
621    }
622
623    // ── resource bindings ─────────────────────────────────────────────────────
624
625    #[test]
626    fn test_bind_resource_to_kernel() -> Result<(), GraphError> {
627        let mut g = ComputeGraph::new();
628        g.add_node(kernel_node(1, [1, 1, 1]))?;
629        g.bind_resource(1, simple_binding("input", 10))?;
630        let node = g.node(1).ok_or(GraphError::NodeNotFound(1))?;
631        assert_eq!(node.bindings.len(), 1);
632        Ok(())
633    }
634
635    #[test]
636    fn test_bind_resource_to_barrier_fails() -> Result<(), GraphError> {
637        let mut g = ComputeGraph::new();
638        g.add_node(barrier_node(1))?;
639        let err = g.bind_resource(1, simple_binding("buf", 0));
640        assert!(matches!(err, Err(GraphError::IncompatibleBinding { .. })));
641        Ok(())
642    }
643
644    #[test]
645    fn test_bind_resource_unknown_node_fails() -> Result<(), GraphError> {
646        let mut g = ComputeGraph::new();
647        let err = g.bind_resource(99, simple_binding("buf", 0));
648        assert!(matches!(err, Err(GraphError::NodeNotFound(99))));
649        Ok(())
650    }
651
652    // ── validate ─────────────────────────────────────────────────────────────
653
654    #[test]
655    fn test_validate_passes_when_all_bound() -> Result<(), GraphError> {
656        let mut g = ComputeGraph::new();
657        let mut n = kernel_node(1, [1, 1, 1]);
658        n.bindings.push(simple_binding("buf", 0));
659        g.add_node(n)?;
660        g.add_node(barrier_node(2))?;
661        g.add_edge(1, 2)?;
662        assert!(g.validate().is_ok());
663        Ok(())
664    }
665
666    #[test]
667    fn test_validate_fails_when_kernel_has_no_bindings() -> Result<(), GraphError> {
668        let mut g = ComputeGraph::new();
669        g.add_node(kernel_node(1, [1, 1, 1]))?;
670        assert!(matches!(
671            g.validate(),
672            Err(GraphError::MissingBinding { node_id: 1, .. })
673        ));
674        Ok(())
675    }
676
677    // ── predecessors / successors ─────────────────────────────────────────────
678
679    #[test]
680    fn test_predecessors_and_successors() -> Result<(), GraphError> {
681        let mut g = ComputeGraph::new();
682        for id in [1, 2, 3] {
683            g.add_node(kernel_node(id, [1, 1, 1]))?;
684        }
685        g.add_edge(1, 3)?;
686        g.add_edge(2, 3)?;
687        let mut preds = g.predecessors(3)?;
688        preds.sort_unstable();
689        assert_eq!(preds, vec![1, 2]);
690        let succs_1 = g.successors(1)?;
691        assert_eq!(succs_1, vec![3]);
692        Ok(())
693    }
694
695    #[test]
696    fn test_predecessors_unknown_node_error() -> Result<(), GraphError> {
697        let g = ComputeGraph::new();
698        assert!(matches!(
699            g.predecessors(42),
700            Err(GraphError::NodeNotFound(42))
701        ));
702        Ok(())
703    }
704}