Skip to main content

nir_rs/
graph.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! In-memory NIR graph model.
4//!
5//! A graph is a named set of computational nodes plus a list of directed
6//! identity edges, matching neuromorphs/NIR (`nodes`, `edges`, `metadata`,
7//! optional `version`). Cycles are allowed; structure validation only checks
8//! edge endpoints, duplicate directed edges, and nested [`NirNode::Graph`]
9//! subgraphs. Validation walks those subgraphs on a heap-allocated work
10//! list so process-stack usage does not grow with nesting depth.
11//! Convolution and pooling parameter invariants are a separate opt-in check
12//! ([`NirGraph::validate_parameters`]).
13
14use crate::error::{NirError, Result};
15use crate::nodes::NirNode;
16use crate::types::MetadataValue;
17use indexmap::IndexMap;
18use std::collections::{HashMap, HashSet};
19
20/// Directed NIR computation graph.
21///
22/// Node insertion order is preserved via [`IndexMap`] (stable iteration for
23/// serialization and debugging). Edges are an ordered list of `(src, dst)`
24/// name pairs.
25#[derive(Debug, Clone, PartialEq, Default)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct NirGraph {
28    /// Named computational nodes (insertion-ordered).
29    pub nodes: IndexMap<String, NirNode>,
30    /// Directed edges as `(source_name, destination_name)`.
31    pub edges: Vec<(String, String)>,
32    /// Free-form graph metadata.
33    pub metadata: HashMap<String, MetadataValue>,
34    /// Optional NIR version string (set when loading from HDF5 in v0.3).
35    pub version: Option<String>,
36}
37
38impl NirGraph {
39    /// Create an empty graph.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Insert a node under `name`.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`NirError::DuplicateNode`] if `name` is already present.
50    pub fn insert_node(&mut self, name: impl Into<String>, node: NirNode) -> Result<()> {
51        let name = name.into();
52        if self.nodes.contains_key(&name) {
53            return Err(NirError::DuplicateNode(name));
54        }
55        self.nodes.insert(name, node);
56        Ok(())
57    }
58
59    /// Append a directed edge `(from → to)` without validating endpoints.
60    ///
61    /// Call [`validate_structure`](Self::validate_structure) to check that
62    /// endpoints exist and that the edge is not duplicated.
63    pub fn add_edge(&mut self, from: impl Into<String>, to: impl Into<String>) {
64        self.edges.push((from.into(), to.into()));
65    }
66
67    /// Borrow the node named `name`, if present.
68    #[must_use]
69    pub fn get(&self, name: &str) -> Option<&NirNode> {
70        self.nodes.get(name)
71    }
72
73    /// Mutably borrow the node named `name`, if present.
74    pub fn get_mut(&mut self, name: &str) -> Option<&mut NirNode> {
75        self.nodes.get_mut(name)
76    }
77
78    /// Number of nodes.
79    #[must_use]
80    pub fn len(&self) -> usize {
81        self.nodes.len()
82    }
83
84    /// Whether the graph has no nodes.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.nodes.is_empty()
88    }
89
90    /// Maximum nesting depth of [`NirNode::Graph`] subgraphs that
91    /// [`Self::validate_structure`] will walk.
92    ///
93    /// The root graph has depth `0`. Each nested [`NirNode::Graph`] increments
94    /// the depth. A subgraph whose depth would exceed this limit is rejected
95    /// with [`NirError::InvalidGraph`] instead of growing the process stack.
96    ///
97    /// This bound is independent of the HDF5 reader/writer nested-graph
98    /// budget: it applies to adversarial in-memory graphs after
99    /// deserialization succeeds; parsing depth is controlled by the serde
100    /// format and deserializer.
101    pub const MAX_NESTING_DEPTH: usize = 1024;
102
103    /// Validate structural integrity.
104    ///
105    /// Checks:
106    /// - every edge endpoint names an existing node
107    /// - no duplicate directed edges `(src, dst)`
108    /// - nested [`NirNode::Graph`] subgraphs also validate, in node insertion
109    ///   order, depth-first
110    ///
111    /// Nested subgraphs are walked with an explicit heap-allocated work list, so
112    /// process-stack usage does not grow with nesting depth. A chain deeper than
113    /// [`Self::MAX_NESTING_DEPTH`] fails with [`NirError::InvalidGraph`].
114    ///
115    /// Cycles are **allowed**. Type/shape inference is out of scope for v0.2.
116    /// Convolution and pooling parameter invariants are checked separately by
117    /// [`validate_parameters`](Self::validate_parameters).
118    ///
119    /// First-error ordering matches a recursive walk: missing endpoints, then
120    /// duplicate edges, then nested subgraphs in insertion order. Nested failures
121    /// are re-prefixed so the message names each enclosing subgraph.
122    ///
123    /// # Errors
124    ///
125    /// - [`NirError::MissingNode`] if an endpoint is unknown
126    /// - [`NirError::DuplicateEdge`] if the same directed edge appears twice
127    /// - [`NirError::InvalidGraph`] if a nested subgraph fails validation, or if
128    ///   nesting exceeds [`Self::MAX_NESTING_DEPTH`]
129    pub fn validate_structure(&self) -> Result<()> {
130        // Heap DFS: process-stack usage is O(1) in nesting depth. Frames are
131        // pushed in reverse insertion order so the first nested graph is
132        // popped next, matching the historical recursive walk.
133        // Path ancestry is stored in a flat arena (`Vec<PathFrame>`) with
134        // index-based parent links. Each frame is Copy (no recursive Drop glue
135        // on worker threads with small stacks) and child nodes share prefix
136        // ancestry in O(1) space.
137        let mut frames: Vec<PathFrame<'_>> = Vec::new();
138        let mut work: Vec<(&NirGraph, Option<usize>)> = vec![(self, None)];
139
140        while let Some((graph, frame_idx)) = work.pop() {
141            graph
142                .validate_local_structure()
143                .map_err(|err| wrap_frame_error(&frames, frame_idx, err))?;
144
145            let parent_depth = frame_idx.map_or(0, |idx| frames[idx].depth);
146
147            let mut nested = Vec::new();
148            for (name, node) in &graph.nodes {
149                let NirNode::Graph(sub) = node else {
150                    continue;
151                };
152                let child_depth = parent_depth + 1;
153                let child_idx = frames.len();
154                frames.push(PathFrame {
155                    name: name.as_str(),
156                    parent: frame_idx,
157                    depth: child_depth,
158                });
159                if parent_depth >= Self::MAX_NESTING_DEPTH {
160                    return Err(wrap_frame_error(
161                        &frames,
162                        Some(child_idx),
163                        NirError::InvalidGraph(format!(
164                            "graph nesting depth exceeds {}",
165                            Self::MAX_NESTING_DEPTH
166                        )),
167                    ));
168                }
169                nested.push((sub.as_ref(), Some(child_idx)));
170            }
171            work.extend(nested.into_iter().rev());
172        }
173
174        Ok(())
175    }
176
177    /// Endpoint and duplicate-edge checks for a single graph, ignoring nesting.
178    fn validate_local_structure(&self) -> Result<()> {
179        let node_keys: HashSet<&str> = self.nodes.keys().map(String::as_str).collect();
180
181        for (src, dst) in &self.edges {
182            if !node_keys.contains(src.as_str()) {
183                return Err(NirError::MissingNode(src.clone()));
184            }
185            if !node_keys.contains(dst.as_str()) {
186                return Err(NirError::MissingNode(dst.clone()));
187            }
188        }
189
190        let mut seen_edges: HashSet<(&str, &str)> = HashSet::new();
191        for (src, dst) in &self.edges {
192            let key = (src.as_str(), dst.as_str());
193            if !seen_edges.insert(key) {
194                return Err(NirError::DuplicateEdge(src.clone(), dst.clone()));
195            }
196        }
197
198        Ok(())
199    }
200
201    /// Validate local convolution and pooling parameter invariants.
202    ///
203    /// Complements [`validate_structure`](Self::validate_structure): structure
204    /// checks only edge endpoints and duplicate directed edges, while this
205    /// walks each node for weight rank, grouped-convolution divisibility,
206    /// stride/dilation/padding extents, bias length, and pooling windows.
207    ///
208    /// HDF5 [`crate::io::read`] does **not** call this method. The default
209    /// writer also does not — invoke it explicitly after assembling or
210    /// importing a graph.
211    ///
212    /// Nested [`crate::NirNode::Graph`] subgraphs are visited. Failures name
213    /// the node with a `/`-separated path (`"encoder/conv"`).
214    ///
215    /// # Errors
216    ///
217    /// [`NirError::InvalidNodeParameters`] for the first node whose
218    /// convolution or pooling fields violate a local invariant.
219    pub fn validate_parameters(&self) -> Result<()> {
220        crate::validation::validate_graph(self)
221    }
222}
223
224/// Parent-linked frame tracking subgraph ancestry with O(1) prefix sharing.
225///
226/// Implements `Copy` so tearing down deep path chains performs no recursive drop
227/// calls on the process stack.
228#[derive(Clone, Copy)]
229struct PathFrame<'a> {
230    name: &'a str,
231    parent: Option<usize>,
232    depth: usize,
233}
234
235/// Prefix a structure-validation error with `in subgraph {name:?}: …`.
236///
237/// Nested [`NirError::InvalidGraph`] payloads are unwrapped so path prefixes
238/// compose the way the historical recursive walk did. Other variants keep their
239/// Display text so future validation errors still surface with path context.
240fn prefix_subgraph_error(name: &str, err: NirError) -> NirError {
241    match err {
242        NirError::MissingNode(n) => {
243            NirError::InvalidGraph(format!("in subgraph {name:?}: missing node: {n}"))
244        }
245        NirError::DuplicateEdge(a, b) => {
246            NirError::InvalidGraph(format!("in subgraph {name:?}: duplicate edge: ({a}, {b})"))
247        }
248        NirError::DuplicateNode(n) => {
249            NirError::InvalidGraph(format!("in subgraph {name:?}: duplicate node: {n}"))
250        }
251        NirError::InvalidGraph(msg) => {
252            NirError::InvalidGraph(format!("in subgraph {name:?}: {msg}"))
253        }
254        other => NirError::InvalidGraph(format!("in subgraph {name:?}: {other}")),
255    }
256}
257
258/// Apply [`prefix_subgraph_error`] from the innermost subgraph out to the root.
259fn wrap_frame_error(
260    frames: &[PathFrame<'_>],
261    mut curr: Option<usize>,
262    mut err: NirError,
263) -> NirError {
264    while let Some(idx) = curr {
265        let frame = &frames[idx];
266        err = prefix_subgraph_error(frame.name, err);
267        curr = frame.parent;
268    }
269    err
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::nodes::{Affine, Input, Lif, Output};
276    use crate::types::Tensor;
277
278    fn input(shape: Vec<usize>) -> NirNode {
279        NirNode::Input(Input {
280            shape,
281            metadata: Default::default(),
282        })
283    }
284
285    fn output(shape: Vec<usize>) -> NirNode {
286        NirNode::Output(Output {
287            shape,
288            metadata: Default::default(),
289        })
290    }
291
292    #[test]
293    fn empty_graph_default() {
294        let g = NirGraph::new();
295        assert!(g.is_empty());
296        assert_eq!(g.len(), 0);
297        assert!(g.validate_structure().is_ok());
298    }
299
300    #[test]
301    fn insert_node_rejects_duplicate() {
302        let mut g = NirGraph::new();
303        g.insert_node("a", input(vec![4])).unwrap();
304        let err = g.insert_node("a", input(vec![2])).unwrap_err();
305        assert_eq!(err, NirError::DuplicateNode("a".into()));
306    }
307
308    #[test]
309    fn get_returns_inserted_node() {
310        let mut g = NirGraph::new();
311        g.insert_node("in", input(vec![3])).unwrap();
312        assert!(matches!(g.get("in"), Some(NirNode::Input(_))));
313        assert!(g.get("missing").is_none());
314    }
315
316    #[test]
317    fn validate_missing_source_endpoint() {
318        let mut g = NirGraph::new();
319        g.insert_node("b", output(vec![1])).unwrap();
320        g.add_edge("ghost", "b");
321        let err = g.validate_structure().unwrap_err();
322        assert_eq!(err, NirError::MissingNode("ghost".into()));
323    }
324
325    #[test]
326    fn validate_missing_dest_endpoint() {
327        let mut g = NirGraph::new();
328        g.insert_node("a", input(vec![1])).unwrap();
329        g.add_edge("a", "ghost");
330        let err = g.validate_structure().unwrap_err();
331        assert_eq!(err, NirError::MissingNode("ghost".into()));
332    }
333
334    #[test]
335    fn validate_duplicate_edge() {
336        let mut g = NirGraph::new();
337        g.insert_node("a", input(vec![1])).unwrap();
338        g.insert_node("b", output(vec![1])).unwrap();
339        g.add_edge("a", "b");
340        g.add_edge("a", "b");
341        let err = g.validate_structure().unwrap_err();
342        assert_eq!(err, NirError::DuplicateEdge("a".into(), "b".into()));
343    }
344
345    #[test]
346    fn cycles_are_allowed() {
347        let mut g = NirGraph::new();
348        g.insert_node("a", input(vec![1])).unwrap();
349        g.insert_node("b", output(vec![1])).unwrap();
350        g.add_edge("a", "b");
351        g.add_edge("b", "a");
352        assert!(g.validate_structure().is_ok());
353    }
354
355    #[test]
356    fn integration_input_affine_lif_output() {
357        let weight = Tensor::from_f32(vec![2, 4], vec![0.1; 8]).unwrap();
358        let bias = Tensor::from_f32(vec![2], vec![0.0, 0.0]).unwrap();
359        let tau = Tensor::from_f64(vec![2], vec![10.0, 10.0]).unwrap();
360        let r = Tensor::from_f64(vec![2], vec![1.0, 1.0]).unwrap();
361        let v_leak = Tensor::from_f64(vec![2], vec![0.0, 0.0]).unwrap();
362        let v_th = Tensor::from_f64(vec![2], vec![1.0, 1.0]).unwrap();
363
364        let mut g = NirGraph::new();
365        g.version = Some("1.0.0".into());
366        g.metadata.insert(
367            "origin".into(),
368            MetadataValue::String("integration-test".into()),
369        );
370
371        g.insert_node("input", input(vec![4])).unwrap();
372        g.insert_node(
373            "fc",
374            NirNode::Affine(Affine {
375                weight,
376                bias,
377                metadata: Default::default(),
378            }),
379        )
380        .unwrap();
381        g.insert_node(
382            "lif",
383            NirNode::Lif(Lif {
384                tau,
385                r,
386                v_leak,
387                v_threshold: v_th,
388                v_reset: None,
389                metadata: Default::default(),
390            }),
391        )
392        .unwrap();
393        g.insert_node("output", output(vec![2])).unwrap();
394
395        g.add_edge("input", "fc");
396        g.add_edge("fc", "lif");
397        g.add_edge("lif", "output");
398
399        assert!(g.validate_structure().is_ok());
400        assert_eq!(g.len(), 4);
401        assert_eq!(g.edges.len(), 3);
402        assert_eq!(g.get("lif").unwrap().type_name(), "LIF");
403        assert_eq!(g.get("fc").unwrap().type_name(), "Affine");
404    }
405
406    #[test]
407    fn nested_graph_validates() {
408        let mut inner = NirGraph::default();
409        inner.insert_node("i", input(vec![1])).unwrap();
410        inner.insert_node("o", output(vec![1])).unwrap();
411        inner.add_edge("i", "o");
412
413        let mut outer = NirGraph::default();
414        outer
415            .insert_node("sub", NirNode::Graph(Box::new(inner)))
416            .unwrap();
417        outer.insert_node("out", output(vec![1])).unwrap();
418        outer.add_edge("sub", "out");
419        assert!(outer.validate_structure().is_ok());
420    }
421
422    #[test]
423    fn nested_graph_reports_inner_failure() {
424        let mut inner = NirGraph::default();
425        inner.insert_node("i", input(vec![1])).unwrap();
426        // edge to missing node
427        inner.add_edge("i", "missing");
428
429        let mut outer = NirGraph::default();
430        outer
431            .insert_node("sub", NirNode::Graph(Box::new(inner)))
432            .unwrap();
433        let err = outer.validate_structure().unwrap_err();
434        assert_eq!(
435            err,
436            NirError::InvalidGraph("in subgraph \"sub\": missing node: missing".into())
437        );
438    }
439
440    #[test]
441    fn nested_duplicate_edge_keeps_path_context() {
442        let mut inner = NirGraph::default();
443        inner.insert_node("a", input(vec![1])).unwrap();
444        inner.insert_node("b", output(vec![1])).unwrap();
445        inner.add_edge("a", "b");
446        inner.add_edge("a", "b");
447
448        let mut outer = NirGraph::default();
449        outer
450            .insert_node("sub", NirNode::Graph(Box::new(inner)))
451            .unwrap();
452        let err = outer.validate_structure().unwrap_err();
453        assert_eq!(
454            err,
455            NirError::InvalidGraph("in subgraph \"sub\": duplicate edge: (a, b)".into())
456        );
457    }
458
459    #[test]
460    fn nested_path_context_is_outermost_first() {
461        let mut inner = NirGraph::default();
462        inner.insert_node("i", input(vec![1])).unwrap();
463        inner.add_edge("i", "ghost");
464
465        let mut mid = NirGraph::default();
466        mid.insert_node("inner", NirNode::Graph(Box::new(inner)))
467            .unwrap();
468
469        let mut outer = NirGraph::default();
470        outer
471            .insert_node("mid", NirNode::Graph(Box::new(mid)))
472            .unwrap();
473        let err = outer.validate_structure().unwrap_err();
474        assert_eq!(
475            err,
476            NirError::InvalidGraph(
477                "in subgraph \"mid\": in subgraph \"inner\": missing node: ghost".into()
478            )
479        );
480    }
481
482    #[test]
483    fn outer_endpoint_error_precedes_nested_failure() {
484        let mut inner = NirGraph::default();
485        inner.insert_node("i", input(vec![1])).unwrap();
486        inner.add_edge("i", "missing_inner");
487
488        let mut outer = NirGraph::default();
489        outer
490            .insert_node("sub", NirNode::Graph(Box::new(inner)))
491            .unwrap();
492        outer.add_edge("ghost", "sub");
493        let err = outer.validate_structure().unwrap_err();
494        assert_eq!(err, NirError::MissingNode("ghost".into()));
495    }
496
497    #[test]
498    fn first_nested_sibling_error_is_reported() {
499        let mut first = NirGraph::default();
500        first.insert_node("i", input(vec![1])).unwrap();
501        first.add_edge("i", "missing_a");
502
503        let mut second = NirGraph::default();
504        second.insert_node("i", input(vec![1])).unwrap();
505        second.add_edge("i", "missing_b");
506
507        let mut outer = NirGraph::default();
508        outer
509            .insert_node("sub_a", NirNode::Graph(Box::new(first)))
510            .unwrap();
511        outer
512            .insert_node("sub_b", NirNode::Graph(Box::new(second)))
513            .unwrap();
514        let err = outer.validate_structure().unwrap_err();
515        assert_eq!(
516            err,
517            NirError::InvalidGraph("in subgraph \"sub_a\": missing node: missing_a".into())
518        );
519    }
520
521    #[test]
522    fn nested_cycles_are_allowed() {
523        let mut inner = NirGraph::default();
524        inner.insert_node("a", input(vec![1])).unwrap();
525        inner.insert_node("b", output(vec![1])).unwrap();
526        inner.add_edge("a", "b");
527        inner.add_edge("b", "a");
528
529        let mut outer = NirGraph::default();
530        outer
531            .insert_node("loop", NirNode::Graph(Box::new(inner)))
532            .unwrap();
533        assert!(outer.validate_structure().is_ok());
534    }
535
536    /// Wrap `leaf` in `depth` enclosing [`NirNode::Graph`] nodes named `n0`…`n{depth-1}`.
537    fn wrap_depth(depth: usize, leaf: NirGraph) -> NirGraph {
538        let mut g = leaf;
539        for i in (0..depth).rev() {
540            let mut outer = NirGraph::default();
541            outer
542                .insert_node(format!("n{i}"), NirNode::Graph(Box::new(g)))
543                .unwrap();
544            g = outer;
545        }
546        g
547    }
548
549    fn leaf_graph() -> NirGraph {
550        let mut g = NirGraph::default();
551        g.insert_node("leaf", input(vec![1])).unwrap();
552        g
553    }
554
555    #[test]
556    fn nesting_at_max_depth_validates() {
557        let g = wrap_depth(NirGraph::MAX_NESTING_DEPTH, leaf_graph());
558        assert!(g.validate_structure().is_ok());
559    }
560
561    #[test]
562    fn nesting_beyond_max_depth_is_invalid_graph() {
563        let g = wrap_depth(NirGraph::MAX_NESTING_DEPTH + 1, leaf_graph());
564        let err = g.validate_structure().unwrap_err();
565        match err {
566            NirError::InvalidGraph(msg) => {
567                assert!(msg.contains("graph nesting depth exceeds"), "{msg}");
568                assert!(
569                    msg.contains(&NirGraph::MAX_NESTING_DEPTH.to_string()),
570                    "{msg}"
571                );
572                assert!(
573                    msg.contains(&format!("n{}", NirGraph::MAX_NESTING_DEPTH)),
574                    "{msg}"
575                );
576                assert!(msg.contains("n0"), "{msg}");
577            }
578            other => panic!("expected InvalidGraph, got {other:?}"),
579        }
580    }
581
582    #[test]
583    fn wide_nesting_is_not_a_depth_limit() {
584        let mut outer = NirGraph::default();
585        for i in 0..64 {
586            outer
587                .insert_node(format!("sub{i}"), NirNode::Graph(Box::new(leaf_graph())))
588                .unwrap();
589        }
590        assert!(outer.validate_structure().is_ok());
591    }
592
593    #[test]
594    fn wide_frontier_with_shared_path_frames_validates() {
595        let mut root = NirGraph::default();
596        for i in 0..50 {
597            let mut sub = NirGraph::default();
598            for j in 0..20 {
599                sub.insert_node(format!("leaf_{j}"), input(vec![1]))
600                    .unwrap();
601            }
602            root.insert_node(format!("branch_{i}"), NirNode::Graph(Box::new(sub)))
603                .unwrap();
604        }
605        assert!(root.validate_structure().is_ok());
606    }
607
608    /// Historical recursive walk used as an oracle for shallow graphs.
609    fn validate_structure_recursive(graph: &NirGraph) -> Result<()> {
610        graph.validate_local_structure()?;
611        for (name, node) in &graph.nodes {
612            if let NirNode::Graph(sub) = node {
613                validate_structure_recursive(sub).map_err(|e| prefix_subgraph_error(name, e))?;
614            }
615        }
616        Ok(())
617    }
618
619    fn assert_matches_recursive(graph: &NirGraph) {
620        assert_eq!(
621            graph.validate_structure(),
622            validate_structure_recursive(graph)
623        );
624    }
625
626    #[test]
627    fn shallow_graphs_match_recursive_oracle() {
628        assert_matches_recursive(&NirGraph::default());
629        assert_matches_recursive(&leaf_graph());
630
631        let mut missing = NirGraph::default();
632        missing.insert_node("a", input(vec![1])).unwrap();
633        missing.add_edge("a", "ghost");
634        assert_matches_recursive(&missing);
635
636        let mut dup = NirGraph::default();
637        dup.insert_node("a", input(vec![1])).unwrap();
638        dup.insert_node("b", output(vec![1])).unwrap();
639        dup.add_edge("a", "b");
640        dup.add_edge("a", "b");
641        assert_matches_recursive(&dup);
642
643        let mut cyc = NirGraph::default();
644        cyc.insert_node("a", input(vec![1])).unwrap();
645        cyc.insert_node("b", output(vec![1])).unwrap();
646        cyc.add_edge("a", "b");
647        cyc.add_edge("b", "a");
648        assert_matches_recursive(&cyc);
649
650        for depth in 0..=4 {
651            let mut inner = NirGraph::default();
652            inner.insert_node("i", input(vec![1])).unwrap();
653            inner.add_edge("i", "ghost");
654            assert_matches_recursive(&wrap_depth(depth, inner));
655            assert_matches_recursive(&wrap_depth(depth, leaf_graph()));
656        }
657
658        let mut first = NirGraph::default();
659        first.insert_node("i", input(vec![1])).unwrap();
660        first.add_edge("i", "missing_a");
661        let mut second = NirGraph::default();
662        second.insert_node("i", input(vec![1])).unwrap();
663        second.add_edge("i", "missing_b");
664        let mut outer = NirGraph::default();
665        outer
666            .insert_node("sub_a", NirNode::Graph(Box::new(first)))
667            .unwrap();
668        outer
669            .insert_node("sub_b", NirNode::Graph(Box::new(second)))
670            .unwrap();
671        outer.add_edge("nope", "sub_a");
672        assert_matches_recursive(&outer);
673    }
674
675    #[test]
676    fn insertion_order_preserved() {
677        let mut g = NirGraph::default();
678        g.insert_node("z", input(vec![1])).unwrap();
679        g.insert_node("a", output(vec![1])).unwrap();
680        let keys: Vec<&str> = g.nodes.keys().map(String::as_str).collect();
681        assert_eq!(keys, ["z", "a"]);
682    }
683
684    #[test]
685    fn small_stack_thread_validates_max_depth_without_overflow() {
686        // Runs validate_structure on a thread with a 64 KiB stack to prove
687        // that neither traversal nor PathFrame teardown recurses on the process stack.
688        let g = wrap_depth(NirGraph::MAX_NESTING_DEPTH, leaf_graph());
689        let builder = std::thread::Builder::new().stack_size(64 * 1024);
690        std::thread::scope(|s| {
691            builder
692                .spawn_scoped(s, || {
693                    assert!(g.validate_structure().is_ok());
694                })
695                .unwrap()
696                .join()
697                .unwrap();
698        });
699    }
700}