Skip to main content

fabryk_graph/
algorithms.rs

1//! Graph algorithms for knowledge graph analysis.
2//!
3//! Provides algorithms for:
4//! - Neighborhood exploration (N-hop BFS with configurable max depth)
5//! - Pathfinding (shortest path between concepts)
6//! - Dependency analysis (prerequisites, topological ordering)
7//! - Centrality analysis (identifying important nodes)
8//! - Bridge detection (nodes connecting different clusters)
9//!
10//! All algorithms are generic and operate on `GraphData`.
11
12use crate::{Edge, GraphData, Node, Relationship};
13use fabryk_core::Result;
14use petgraph::Direction;
15use petgraph::algo::toposort;
16use petgraph::graph::NodeIndex;
17use petgraph::visit::EdgeRef;
18use std::collections::{HashMap, HashSet, VecDeque};
19
20/// Maximum allowed BFS depth to prevent runaway traversals.
21const MAX_BFS_DEPTH: usize = 10;
22
23// ============================================================================
24// Result types
25// ============================================================================
26
27/// Result of a neighborhood query.
28#[derive(Clone, Debug)]
29pub struct NeighborhoodResult {
30    /// The central node.
31    pub center: Node,
32    /// Nodes within the specified radius.
33    pub nodes: Vec<Node>,
34    /// Edges connecting the neighborhood nodes.
35    pub edges: Vec<Edge>,
36    /// Distance from center to each node (by node ID).
37    pub distances: HashMap<String, usize>,
38}
39
40/// Result of a shortest path query.
41#[derive(Clone, Debug)]
42pub struct PathResult {
43    /// Nodes along the path, in order.
44    pub path: Vec<Node>,
45    /// Edges along the path.
46    pub edges: Vec<Edge>,
47    /// Total path weight.
48    pub total_weight: f32,
49    /// Whether a path was found.
50    pub found: bool,
51}
52
53impl PathResult {
54    /// Creates an empty result indicating no path found.
55    pub fn not_found() -> Self {
56        Self {
57            path: Vec::new(),
58            edges: Vec::new(),
59            total_weight: 0.0,
60            found: false,
61        }
62    }
63}
64
65/// Result of prerequisites analysis.
66#[derive(Clone, Debug)]
67pub struct PrerequisitesResult {
68    /// Prerequisites in topological order (learn first → learn last).
69    pub ordered: Vec<Node>,
70    /// The target node.
71    pub target: Node,
72    /// Whether cycles were detected (if true, ordering is approximate).
73    pub has_cycles: bool,
74}
75
76/// Centrality scores for a node.
77#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
78pub struct CentralityScore {
79    /// Node ID.
80    pub node_id: String,
81    /// Degree centrality (normalized: 0.0 to 1.0).
82    pub degree: f32,
83    /// In-degree centrality (how many nodes point to this).
84    pub in_degree: f32,
85    /// Out-degree centrality (how many nodes this points to).
86    pub out_degree: f32,
87}
88
89// ============================================================================
90// Algorithms
91// ============================================================================
92
93/// Get the N-hop neighborhood around a node.
94///
95/// Performs a breadth-first search from the center node, collecting all
96/// nodes and edges within the specified radius. Depth is capped at
97/// `MAX_BFS_DEPTH` (10) to prevent runaway traversals.
98///
99/// # Arguments
100///
101/// * `graph` - The graph to search
102/// * `center_id` - ID of the center node
103/// * `radius` - Maximum distance from center (hops), capped at 10
104/// * `relationship_filter` - Optional filter for edge types to follow
105pub fn neighborhood(
106    graph: &GraphData,
107    center_id: &str,
108    radius: usize,
109    relationship_filter: Option<&[Relationship]>,
110) -> Result<NeighborhoodResult> {
111    let center_node = graph
112        .get_node(center_id)
113        .ok_or_else(|| fabryk_core::Error::not_found("node", center_id))?
114        .clone();
115
116    let center_idx = graph
117        .get_index(center_id)
118        .ok_or_else(|| fabryk_core::Error::not_found("node index", center_id))?;
119
120    let radius = radius.min(MAX_BFS_DEPTH);
121
122    let mut visited: HashSet<NodeIndex> = HashSet::new();
123    let mut distances: HashMap<String, usize> = HashMap::new();
124    let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
125    let mut result_nodes: Vec<Node> = Vec::new();
126    let mut result_edges: Vec<Edge> = Vec::new();
127
128    visited.insert(center_idx);
129    distances.insert(center_id.to_string(), 0);
130    queue.push_back((center_idx, 0));
131
132    while let Some((current_idx, current_dist)) = queue.pop_front() {
133        if current_dist >= radius {
134            continue;
135        }
136
137        // Explore outgoing edges
138        for edge_ref in graph.graph.edges_directed(current_idx, Direction::Outgoing) {
139            let edge_weight = edge_ref.weight();
140
141            if let Some(filter) = relationship_filter
142                && !filter.contains(&edge_weight.relationship)
143            {
144                continue;
145            }
146
147            let neighbor_idx = edge_ref.target();
148            if visited.insert(neighbor_idx) {
149                let neighbor = &graph.graph[neighbor_idx];
150                distances.insert(neighbor.id.clone(), current_dist + 1);
151                result_nodes.push(neighbor.clone());
152                result_edges.push(edge_weight.clone());
153                queue.push_back((neighbor_idx, current_dist + 1));
154            }
155        }
156
157        // Explore incoming edges
158        for edge_ref in graph.graph.edges_directed(current_idx, Direction::Incoming) {
159            let edge_weight = edge_ref.weight();
160
161            if let Some(filter) = relationship_filter
162                && !filter.contains(&edge_weight.relationship)
163            {
164                continue;
165            }
166
167            let neighbor_idx = edge_ref.source();
168            if visited.insert(neighbor_idx) {
169                let neighbor = &graph.graph[neighbor_idx];
170                distances.insert(neighbor.id.clone(), current_dist + 1);
171                result_nodes.push(neighbor.clone());
172                result_edges.push(edge_weight.clone());
173                queue.push_back((neighbor_idx, current_dist + 1));
174            }
175        }
176    }
177
178    Ok(NeighborhoodResult {
179        center: center_node,
180        nodes: result_nodes,
181        edges: result_edges,
182        distances,
183    })
184}
185
186/// Find the shortest path between two nodes using A* search.
187///
188/// Uses petgraph's A* algorithm with uniform cost.
189pub fn shortest_path(graph: &GraphData, from_id: &str, to_id: &str) -> Result<PathResult> {
190    let from_idx = match graph.get_index(from_id) {
191        Some(idx) => idx,
192        None => return Ok(PathResult::not_found()),
193    };
194
195    let to_idx = match graph.get_index(to_id) {
196        Some(idx) => idx,
197        None => return Ok(PathResult::not_found()),
198    };
199
200    if from_idx == to_idx {
201        let node = graph.graph[from_idx].clone();
202        return Ok(PathResult {
203            path: vec![node],
204            edges: Vec::new(),
205            total_weight: 0.0,
206            found: true,
207        });
208    }
209
210    let result = petgraph::algo::astar(
211        &graph.graph,
212        from_idx,
213        |n| n == to_idx,
214        |e| (1.0 / e.weight().weight.max(0.01)) as u32,
215        |_| 0,
216    );
217
218    match result {
219        Some((_cost, path_indices)) => {
220            let path: Vec<Node> = path_indices
221                .iter()
222                .map(|&idx| graph.graph[idx].clone())
223                .collect();
224
225            let mut edges = Vec::new();
226            for window in path_indices.windows(2) {
227                if let [from, to] = window
228                    && let Some(edge_idx) = graph.graph.find_edge(*from, *to)
229                {
230                    edges.push(graph.graph[edge_idx].clone());
231                }
232            }
233
234            let total_weight = edges.iter().map(|e| e.weight).sum();
235
236            Ok(PathResult {
237                path,
238                edges,
239                total_weight,
240                found: true,
241            })
242        }
243        None => Ok(PathResult::not_found()),
244    }
245}
246
247/// Get prerequisites for a concept in topological order.
248///
249/// Follows `Prerequisite` edges backwards to find all dependencies,
250/// then returns them in learning order (fundamentals first).
251pub fn prerequisites_sorted(graph: &GraphData, target_id: &str) -> Result<PrerequisitesResult> {
252    let target_node = graph
253        .get_node(target_id)
254        .ok_or_else(|| fabryk_core::Error::not_found("node", target_id))?
255        .clone();
256
257    let target_idx = graph.get_index(target_id).unwrap();
258
259    // Collect all prerequisites (recursive BFS backwards through Prerequisite edges)
260    let mut prereq_indices: HashSet<NodeIndex> = HashSet::new();
261    let mut queue: VecDeque<NodeIndex> = VecDeque::new();
262    queue.push_back(target_idx);
263
264    while let Some(current) = queue.pop_front() {
265        for edge_ref in graph.graph.edges_directed(current, Direction::Incoming) {
266            if edge_ref.weight().relationship == Relationship::Prerequisite {
267                let source = edge_ref.source();
268                if prereq_indices.insert(source) {
269                    queue.push_back(source);
270                }
271            }
272        }
273    }
274
275    if prereq_indices.is_empty() {
276        return Ok(PrerequisitesResult {
277            ordered: Vec::new(),
278            target: target_node,
279            has_cycles: false,
280        });
281    }
282
283    // Try topological sort of the full graph
284    let sorted = toposort(&graph.graph, None);
285
286    let (ordered, has_cycles) = match sorted {
287        Ok(all_sorted) => {
288            // Filter to just our prerequisites, maintaining topo order
289            let ordered: Vec<Node> = all_sorted
290                .into_iter()
291                .filter(|idx| prereq_indices.contains(idx))
292                .map(|idx| graph.graph[idx].clone())
293                .collect();
294            (ordered, false)
295        }
296        Err(_) => {
297            // Cycle detected - return in arbitrary order
298            let ordered: Vec<Node> = prereq_indices
299                .iter()
300                .map(|idx| graph.graph[*idx].clone())
301                .collect();
302            (ordered, true)
303        }
304    };
305
306    Ok(PrerequisitesResult {
307        ordered,
308        target: target_node,
309        has_cycles,
310    })
311}
312
313/// Calculate centrality scores for all nodes.
314///
315/// Computes degree-based centrality metrics for each node.
316/// Returns scores sorted by degree (descending).
317pub fn calculate_centrality(graph: &GraphData) -> Vec<CentralityScore> {
318    let n = graph.node_count() as f32;
319    if n < 2.0 {
320        return Vec::new();
321    }
322
323    let mut scores: Vec<CentralityScore> = graph
324        .iter_nodes()
325        .map(|node| {
326            let idx = graph.get_index(&node.id).unwrap();
327
328            let in_deg = graph.graph.edges_directed(idx, Direction::Incoming).count() as f32;
329            let out_deg = graph.graph.edges_directed(idx, Direction::Outgoing).count() as f32;
330            let degree = in_deg + out_deg;
331
332            CentralityScore {
333                node_id: node.id.clone(),
334                degree: degree / (2.0 * (n - 1.0)),
335                in_degree: in_deg / (n - 1.0),
336                out_degree: out_deg / (n - 1.0),
337            }
338        })
339        .collect();
340
341    scores.sort_by(|a, b| {
342        b.degree
343            .partial_cmp(&a.degree)
344            .unwrap_or(std::cmp::Ordering::Equal)
345    });
346    scores
347}
348
349/// Find bridge concepts connecting different clusters.
350///
351/// Identifies nodes that connect otherwise-distant parts of the graph,
352/// using connectivity × category diversity as a heuristic score.
353pub fn find_bridges(graph: &GraphData, limit: usize) -> Vec<Node> {
354    let mut bridge_scores: Vec<(String, f32)> = graph
355        .iter_nodes()
356        .map(|node| {
357            let idx = graph.get_index(&node.id).unwrap();
358
359            let in_deg = graph.graph.edges_directed(idx, Direction::Incoming).count() as f32;
360            let out_deg = graph.graph.edges_directed(idx, Direction::Outgoing).count() as f32;
361
362            // Collect unique categories of neighbors
363            let mut neighbor_categories: HashSet<String> = HashSet::new();
364            for edge_ref in graph.graph.edges(idx) {
365                let neighbor = &graph.graph[edge_ref.target()];
366                if let Some(ref cat) = neighbor.category {
367                    neighbor_categories.insert(cat.clone());
368                }
369            }
370
371            // Bridge score: connectivity × category diversity
372            let diversity = neighbor_categories.len() as f32;
373            let score = (in_deg.min(out_deg) + 1.0) * (diversity + 1.0);
374
375            (node.id.clone(), score)
376        })
377        .collect();
378
379    bridge_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
380
381    bridge_scores
382        .into_iter()
383        .take(limit)
384        .filter_map(|(id, _)| graph.get_node(&id).cloned())
385        .collect()
386}
387
388/// Get nodes related to a given node by specific relationship types.
389pub fn get_related(
390    graph: &GraphData,
391    node_id: &str,
392    relationships: &[Relationship],
393    direction: Direction,
394) -> Result<Vec<(Node, Relationship)>> {
395    let idx = graph
396        .get_index(node_id)
397        .ok_or_else(|| fabryk_core::Error::not_found("node", node_id))?;
398
399    let mut results = Vec::new();
400
401    let edges: Box<dyn Iterator<Item = _>> = match direction {
402        Direction::Outgoing => Box::new(graph.graph.edges_directed(idx, Direction::Outgoing)),
403        Direction::Incoming => Box::new(graph.graph.edges_directed(idx, Direction::Incoming)),
404    };
405
406    for edge_ref in edges {
407        let edge = edge_ref.weight();
408        if relationships.contains(&edge.relationship) {
409            let neighbor_idx = match direction {
410                Direction::Outgoing => edge_ref.target(),
411                Direction::Incoming => edge_ref.source(),
412            };
413            let neighbor = graph.graph[neighbor_idx].clone();
414            results.push((neighbor, edge.relationship.clone()));
415        }
416    }
417
418    Ok(results)
419}
420
421// ============================================================================
422// Typed convenience methods on GraphData (adapted from Taproot)
423// ============================================================================
424
425impl GraphData {
426    /// Get direct prerequisites of a node (incoming Prerequisite edges).
427    pub fn prerequisites(&self, node_id: &str) -> Result<Vec<Node>> {
428        let results = get_related(
429            self,
430            node_id,
431            &[Relationship::Prerequisite],
432            Direction::Outgoing,
433        )?;
434        Ok(results.into_iter().map(|(node, _)| node).collect())
435    }
436
437    /// Get nodes that depend on this node (outgoing Prerequisite edges pointing here).
438    pub fn dependents(&self, node_id: &str) -> Result<Vec<Node>> {
439        let results = get_related(
440            self,
441            node_id,
442            &[Relationship::Prerequisite],
443            Direction::Incoming,
444        )?;
445        Ok(results.into_iter().map(|(node, _)| node).collect())
446    }
447
448    /// Get nodes related by a specific relationship type (outgoing direction).
449    pub fn related_by(&self, node_id: &str, relationship: &Relationship) -> Result<Vec<Node>> {
450        let results = get_related(
451            self,
452            node_id,
453            std::slice::from_ref(relationship),
454            Direction::Outgoing,
455        )?;
456        Ok(results.into_iter().map(|(node, _)| node).collect())
457    }
458}
459
460// ============================================================================
461// Tests
462// ============================================================================
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::types::*;
468
469    fn create_test_graph() -> GraphData {
470        let mut graph = GraphData::new();
471
472        // Create a simple graph: A -> B -> C, A -> D, B -> D
473        let nodes = vec![
474            Node::new("a", "Node A").with_category("cat1"),
475            Node::new("b", "Node B").with_category("cat1"),
476            Node::new("c", "Node C").with_category("cat2"),
477            Node::new("d", "Node D").with_category("cat2"),
478        ];
479
480        for node in &nodes {
481            graph.add_node(node.clone());
482        }
483
484        let edges = vec![
485            Edge::new("a", "b", Relationship::Prerequisite),
486            Edge::new("b", "c", Relationship::Prerequisite),
487            Edge::new("a", "d", Relationship::RelatesTo),
488            Edge::new("b", "d", Relationship::LeadsTo),
489        ];
490
491        for edge in edges {
492            graph.add_edge(edge).unwrap();
493        }
494
495        graph
496    }
497
498    // ------------------------------------------------------------------------
499    // Neighborhood tests
500    // ------------------------------------------------------------------------
501
502    #[test]
503    fn test_neighborhood_basic() {
504        let graph = create_test_graph();
505        let result = neighborhood(&graph, "a", 1, None).unwrap();
506
507        assert_eq!(result.center.id, "a");
508        assert_eq!(result.nodes.len(), 2); // b and d
509        assert!(result.nodes.iter().any(|n| n.id == "b"));
510        assert!(result.nodes.iter().any(|n| n.id == "d"));
511    }
512
513    #[test]
514    fn test_neighborhood_radius_2() {
515        let graph = create_test_graph();
516        let result = neighborhood(&graph, "a", 2, None).unwrap();
517
518        assert!(result.nodes.iter().any(|n| n.id == "c"));
519        assert_eq!(result.distances["c"], 2);
520    }
521
522    #[test]
523    fn test_neighborhood_with_filter() {
524        let graph = create_test_graph();
525        let result = neighborhood(&graph, "a", 2, Some(&[Relationship::Prerequisite])).unwrap();
526
527        assert!(result.nodes.iter().any(|n| n.id == "b"));
528        assert!(result.nodes.iter().any(|n| n.id == "c"));
529        // d should NOT be included (connected via RelatesTo)
530        assert!(!result.nodes.iter().any(|n| n.id == "d"));
531    }
532
533    #[test]
534    fn test_neighborhood_not_found() {
535        let graph = create_test_graph();
536        let result = neighborhood(&graph, "nonexistent", 1, None);
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn test_neighborhood_empty_graph() {
542        let graph = GraphData::new();
543        let result = neighborhood(&graph, "x", 1, None);
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn test_neighborhood_radius_zero() {
549        let graph = create_test_graph();
550        let result = neighborhood(&graph, "a", 0, None).unwrap();
551
552        assert_eq!(result.center.id, "a");
553        assert!(result.nodes.is_empty());
554    }
555
556    #[test]
557    fn test_neighborhood_depth_capped() {
558        let graph = create_test_graph();
559        // Request radius 100, should be capped to MAX_BFS_DEPTH
560        let result = neighborhood(&graph, "a", 100, None).unwrap();
561        // Should find all reachable nodes without infinite loop
562        assert!(!result.nodes.is_empty());
563    }
564
565    // ------------------------------------------------------------------------
566    // Shortest path tests
567    // ------------------------------------------------------------------------
568
569    #[test]
570    fn test_shortest_path_found() {
571        let graph = create_test_graph();
572        let result = shortest_path(&graph, "a", "c").unwrap();
573
574        assert!(result.found);
575        assert!(!result.path.is_empty());
576        assert_eq!(result.path.first().unwrap().id, "a");
577        assert_eq!(result.path.last().unwrap().id, "c");
578    }
579
580    #[test]
581    fn test_shortest_path_not_found() {
582        let graph = create_test_graph();
583        let result = shortest_path(&graph, "c", "a").unwrap();
584        assert!(!result.found);
585    }
586
587    #[test]
588    fn test_shortest_path_same_node() {
589        let graph = create_test_graph();
590        let result = shortest_path(&graph, "a", "a").unwrap();
591        assert!(result.found);
592        assert_eq!(result.path.len(), 1);
593        assert_eq!(result.total_weight, 0.0);
594    }
595
596    #[test]
597    fn test_shortest_path_missing_from() {
598        let graph = create_test_graph();
599        let result = shortest_path(&graph, "missing", "a").unwrap();
600        assert!(!result.found);
601    }
602
603    #[test]
604    fn test_shortest_path_missing_to() {
605        let graph = create_test_graph();
606        let result = shortest_path(&graph, "a", "missing").unwrap();
607        assert!(!result.found);
608    }
609
610    // ------------------------------------------------------------------------
611    // Prerequisites tests
612    // ------------------------------------------------------------------------
613
614    #[test]
615    fn test_prerequisites_sorted() {
616        let graph = create_test_graph();
617        let result = prerequisites_sorted(&graph, "c").unwrap();
618
619        assert_eq!(result.target.id, "c");
620        assert!(!result.has_cycles);
621        assert!(result.ordered.iter().any(|n| n.id == "a"));
622        assert!(result.ordered.iter().any(|n| n.id == "b"));
623        // a should come before b (a is prereq of b, b is prereq of c)
624        let a_pos = result.ordered.iter().position(|n| n.id == "a").unwrap();
625        let b_pos = result.ordered.iter().position(|n| n.id == "b").unwrap();
626        assert!(a_pos < b_pos);
627    }
628
629    #[test]
630    fn test_prerequisites_no_deps() {
631        let graph = create_test_graph();
632        let result = prerequisites_sorted(&graph, "a").unwrap();
633
634        assert_eq!(result.target.id, "a");
635        assert!(result.ordered.is_empty());
636    }
637
638    #[test]
639    fn test_prerequisites_not_found() {
640        let graph = create_test_graph();
641        let result = prerequisites_sorted(&graph, "nonexistent");
642        assert!(result.is_err());
643    }
644
645    // ------------------------------------------------------------------------
646    // Centrality tests
647    // ------------------------------------------------------------------------
648
649    #[test]
650    fn test_calculate_centrality() {
651        let graph = create_test_graph();
652        let scores = calculate_centrality(&graph);
653
654        assert_eq!(scores.len(), 4);
655        for score in &scores {
656            assert!(score.degree >= 0.0 && score.degree <= 1.0);
657            assert!(score.in_degree >= 0.0 && score.in_degree <= 1.0);
658            assert!(score.out_degree >= 0.0 && score.out_degree <= 1.0);
659        }
660    }
661
662    #[test]
663    fn test_calculate_centrality_empty() {
664        let graph = GraphData::new();
665        let scores = calculate_centrality(&graph);
666        assert!(scores.is_empty());
667    }
668
669    #[test]
670    fn test_calculate_centrality_single_node() {
671        let mut graph = GraphData::new();
672        graph.add_node(Node::new("a", "A"));
673        let scores = calculate_centrality(&graph);
674        // Less than 2 nodes => empty
675        assert!(scores.is_empty());
676    }
677
678    // ------------------------------------------------------------------------
679    // Bridge detection tests
680    // ------------------------------------------------------------------------
681
682    #[test]
683    fn test_find_bridges() {
684        let graph = create_test_graph();
685        let bridges = find_bridges(&graph, 2);
686
687        assert!(!bridges.is_empty());
688        assert!(bridges.len() <= 2);
689    }
690
691    #[test]
692    fn test_find_bridges_empty() {
693        let graph = GraphData::new();
694        let bridges = find_bridges(&graph, 5);
695        assert!(bridges.is_empty());
696    }
697
698    // ------------------------------------------------------------------------
699    // get_related tests
700    // ------------------------------------------------------------------------
701
702    #[test]
703    fn test_get_related_outgoing() {
704        let graph = create_test_graph();
705        let related = get_related(
706            &graph,
707            "a",
708            &[Relationship::Prerequisite],
709            Direction::Outgoing,
710        )
711        .unwrap();
712
713        assert_eq!(related.len(), 1);
714        assert_eq!(related[0].0.id, "b");
715        assert_eq!(related[0].1, Relationship::Prerequisite);
716    }
717
718    #[test]
719    fn test_get_related_incoming() {
720        let graph = create_test_graph();
721        let related = get_related(
722            &graph,
723            "b",
724            &[Relationship::Prerequisite],
725            Direction::Incoming,
726        )
727        .unwrap();
728
729        assert_eq!(related.len(), 1);
730        assert_eq!(related[0].0.id, "a");
731    }
732
733    #[test]
734    fn test_get_related_not_found() {
735        let graph = create_test_graph();
736        let result = get_related(
737            &graph,
738            "nonexistent",
739            &[Relationship::Prerequisite],
740            Direction::Outgoing,
741        );
742        assert!(result.is_err());
743    }
744
745    // ------------------------------------------------------------------------
746    // Typed convenience method tests
747    // ------------------------------------------------------------------------
748
749    #[test]
750    fn test_graph_data_prerequisites() {
751        let graph = create_test_graph();
752        let prereqs = graph.prerequisites("a").unwrap();
753        // a -> b is Prerequisite (outgoing from a, so b is "what a requires")
754        assert_eq!(prereqs.len(), 1);
755        assert_eq!(prereqs[0].id, "b");
756    }
757
758    #[test]
759    fn test_graph_data_dependents() {
760        let graph = create_test_graph();
761        // b has an incoming Prerequisite from a
762        let deps = graph.dependents("b").unwrap();
763        assert_eq!(deps.len(), 1);
764        assert_eq!(deps[0].id, "a");
765    }
766
767    #[test]
768    fn test_graph_data_related_by() {
769        let graph = create_test_graph();
770        let related = graph.related_by("a", &Relationship::RelatesTo).unwrap();
771        assert_eq!(related.len(), 1);
772        assert_eq!(related[0].id, "d");
773    }
774}