Skip to main content

dpp_domain/domain/
graph.rs

1//! Bill-of-materials graph checks over local passport component edges.
2//!
3//! Pure and greenfield. The engine builds the *local* adjacency — each passport
4//! id mapped to the ids of the component passports it holds in the same repo —
5//! and this module decides whether a new `parent → child` edge is safe to add.
6//!
7//! A cross-operator component reference cannot be resolved to a local id without
8//! a network fetch, so its cycle safety is necessarily a verify-time concern,
9//! not an insertion-time guarantee. This module therefore only ever reasons over
10//! the local subgraph; the recursive verify walk (built on top of this) is what
11//! catches a cycle that only closes across operators.
12
13use std::collections::{HashMap, HashSet};
14
15use crate::domain::passport::PassportId;
16
17/// Adjacency of the local component graph: each passport id to the ids of its
18/// direct, locally-held component passports.
19pub type ComponentEdges = HashMap<PassportId, Vec<PassportId>>;
20
21/// The default maximum BOM depth for a `child` sub-assembly (the child itself is
22/// depth 1). Deep enough for pack → module → cell and then some; small enough
23/// that the reachability check can never be turned into a DoS vector.
24pub const DEFAULT_DEPTH_CAP: usize = 6;
25
26/// Why a `parent → child` component edge was refused.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum EdgeRejection {
29    /// `child` already reaches `parent`, so the edge would close a cycle.
30    Cycle,
31    /// `child`'s sub-assembly is already `depth_cap` levels deep; nesting it
32    /// under `parent` would exceed the maximum modelled BOM depth.
33    DepthExceeded,
34}
35
36/// Decide whether the edge `parent → child` may be added to the local component
37/// graph `edges` (adjacency: passport id → its direct local component ids).
38///
39/// Walks only `child`'s reachable subtree, bounded to `depth_cap` levels, and
40/// refuses if it reaches `parent` (a cycle) or if the subtree is already at the
41/// cap (would exceed the maximum depth). A `visited` set makes shared
42/// sub-assemblies (diamonds) and any pre-existing cycle in `edges` safe to walk,
43/// so the check always terminates.
44///
45/// Reachability is exact regardless of `depth_cap`: if `parent` is reachable
46/// from `child`, the walk finds it. The depth bound is a structural cap and DoS
47/// guard, and `Cycle` takes priority over `DepthExceeded` when both would apply.
48pub fn check_edge(
49    edges: &ComponentEdges,
50    parent: PassportId,
51    child: PassportId,
52    depth_cap: usize,
53) -> Result<(), EdgeRejection> {
54    if parent == child {
55        return Err(EdgeRejection::Cycle);
56    }
57    let mut visited = HashSet::new();
58    // DFS from child; depth 1 = child sitting directly under parent.
59    let mut stack = vec![(child, 1usize)];
60    while let Some((node, depth)) = stack.pop() {
61        if node == parent {
62            return Err(EdgeRejection::Cycle);
63        }
64        if depth > depth_cap {
65            return Err(EdgeRejection::DepthExceeded);
66        }
67        if !visited.insert(node) {
68            continue;
69        }
70        if let Some(children) = edges.get(&node) {
71            for &c in children {
72                stack.push((c, depth + 1));
73            }
74        }
75    }
76    Ok(())
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn id() -> PassportId {
84        PassportId::new()
85    }
86
87    #[test]
88    fn independent_child_is_accepted() {
89        let edges = ComponentEdges::new();
90        let (parent, child) = (id(), id());
91        assert_eq!(check_edge(&edges, parent, child, DEFAULT_DEPTH_CAP), Ok(()));
92    }
93
94    #[test]
95    fn self_edge_is_a_cycle() {
96        let edges = ComponentEdges::new();
97        let p = id();
98        assert_eq!(
99            check_edge(&edges, p, p, DEFAULT_DEPTH_CAP),
100            Err(EdgeRejection::Cycle)
101        );
102    }
103
104    #[test]
105    fn direct_back_edge_is_a_cycle() {
106        // child already lists parent as one of its components.
107        let (parent, child) = (id(), id());
108        let mut edges = ComponentEdges::new();
109        edges.insert(child, vec![parent]);
110        assert_eq!(
111            check_edge(&edges, parent, child, DEFAULT_DEPTH_CAP),
112            Err(EdgeRejection::Cycle)
113        );
114    }
115
116    #[test]
117    fn transitive_back_edge_is_a_cycle() {
118        // child → mid → parent : adding parent → child closes the loop.
119        let (parent, child, mid) = (id(), id(), id());
120        let mut edges = ComponentEdges::new();
121        edges.insert(child, vec![mid]);
122        edges.insert(mid, vec![parent]);
123        assert_eq!(
124            check_edge(&edges, parent, child, DEFAULT_DEPTH_CAP),
125            Err(EdgeRejection::Cycle)
126        );
127    }
128
129    #[test]
130    fn shared_subcomponent_diamond_is_not_a_cycle() {
131        // child → {a, b}, and both a and b → leaf. A diamond, not a cycle.
132        let (parent, child, a, b, leaf) = (id(), id(), id(), id(), id());
133        let mut edges = ComponentEdges::new();
134        edges.insert(child, vec![a, b]);
135        edges.insert(a, vec![leaf]);
136        edges.insert(b, vec![leaf]);
137        assert_eq!(check_edge(&edges, parent, child, DEFAULT_DEPTH_CAP), Ok(()));
138    }
139
140    #[test]
141    fn subtree_deeper_than_cap_is_refused() {
142        // A straight chain child → n1 → n2 … deeper than the cap.
143        let parent = id();
144        let chain: Vec<PassportId> = (0..8).map(|_| id()).collect();
145        let mut edges = ComponentEdges::new();
146        for pair in chain.windows(2) {
147            edges.insert(pair[0], vec![pair[1]]);
148        }
149        assert_eq!(
150            check_edge(&edges, parent, chain[0], 3),
151            Err(EdgeRejection::DepthExceeded)
152        );
153        // A cap that comfortably covers the chain accepts it.
154        assert_eq!(check_edge(&edges, parent, chain[0], 32), Ok(()));
155    }
156
157    #[test]
158    fn pre_existing_cycle_in_edges_still_terminates() {
159        // The adjacency itself already contains a loop (x → y → x). The check
160        // must terminate via the visited set rather than spin forever; since the
161        // unrelated `parent` is not reachable from the loop, it is safe to add.
162        let (parent, x, y) = (id(), id(), id());
163        let mut edges = ComponentEdges::new();
164        edges.insert(x, vec![y]);
165        edges.insert(y, vec![x]);
166        assert_eq!(check_edge(&edges, parent, x, 32), Ok(()));
167    }
168}