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}