Skip to main content

brainwires_cognition/knowledge/
relationship_graph.rs

1//! Relationship Graph Storage
2//!
3//! Stores and queries entity relationships for enhanced context retrieval.
4//! Uses an in-memory graph with optional persistence to LanceDB.
5
6use crate::knowledge::entity::{Entity, EntityStore, EntityType, Relationship};
7use std::collections::{HashMap, HashSet, VecDeque};
8
9// Re-export graph types from core (canonical definitions)
10pub use brainwires_core::graph::{EdgeType, GraphEdge, GraphNode};
11
12/// Relationship graph for entity context
13#[derive(Debug, Default)]
14pub struct RelationshipGraph {
15    nodes: HashMap<String, GraphNode>,
16    edges: Vec<GraphEdge>,
17    adjacency: HashMap<String, Vec<usize>>, // node -> edge indices
18}
19
20impl RelationshipGraph {
21    /// Create a new empty relationship graph.
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Build graph from entity store
27    pub fn from_entity_store(store: &EntityStore) -> Self {
28        let mut graph = Self::new();
29
30        // Add nodes from top entities
31        for entity in store.get_top_entities(100) {
32            graph.add_node(GraphNode {
33                entity_name: entity.name.clone(),
34                entity_type: entity.entity_type.clone(),
35                message_ids: entity.message_ids.clone(),
36                mention_count: entity.mention_count,
37                importance: Self::calculate_importance(entity),
38            });
39        }
40
41        graph
42    }
43
44    /// Calculate importance score for an entity
45    fn calculate_importance(entity: &Entity) -> f32 {
46        let mut score = 0.0;
47
48        // Base score from mentions
49        score += (entity.mention_count as f32).ln().max(0.0) * 0.3;
50
51        // Type-based importance
52        score += match entity.entity_type {
53            EntityType::File => 0.4,
54            EntityType::Function => 0.3,
55            EntityType::Type => 0.35,
56            EntityType::Error => 0.25,
57            EntityType::Concept => 0.2,
58            EntityType::Variable => 0.1,
59            EntityType::Command => 0.15,
60        };
61
62        // Recency (would need timestamp context)
63        // For now, use message count as proxy
64        score += (entity.message_ids.len() as f32 * 0.05).min(0.2);
65
66        score.clamp(0.0, 1.0)
67    }
68
69    /// Add a node to the graph
70    pub fn add_node(&mut self, node: GraphNode) {
71        let name = node.entity_name.clone();
72        if !self.adjacency.contains_key(&name) {
73            self.adjacency.insert(name.clone(), Vec::new());
74        }
75        self.nodes.insert(name, node);
76    }
77
78    /// Add an edge to the graph
79    pub fn add_edge(&mut self, edge: GraphEdge) {
80        let idx = self.edges.len();
81
82        // Update adjacency list for both directions
83        if let Some(adj) = self.adjacency.get_mut(&edge.from) {
84            adj.push(idx);
85        }
86        if let Some(adj) = self.adjacency.get_mut(&edge.to) {
87            adj.push(idx);
88        }
89
90        self.edges.push(edge);
91    }
92
93    /// Add relationship as edge
94    pub fn add_relationship(&mut self, rel: &Relationship) {
95        let (from, to, edge_type, message_id) = match rel {
96            Relationship::CoOccurs {
97                entity_a,
98                entity_b,
99                message_id,
100            } => (
101                entity_a.clone(),
102                entity_b.clone(),
103                EdgeType::CoOccurs,
104                Some(message_id.clone()),
105            ),
106            Relationship::Contains {
107                container,
108                contained,
109            } => (
110                container.clone(),
111                contained.clone(),
112                EdgeType::Contains,
113                None,
114            ),
115            Relationship::References { from, to } => {
116                (from.clone(), to.clone(), EdgeType::References, None)
117            }
118            Relationship::DependsOn {
119                dependent,
120                dependency,
121            } => (
122                dependent.clone(),
123                dependency.clone(),
124                EdgeType::DependsOn,
125                None,
126            ),
127            Relationship::Modifies {
128                modifier, modified, ..
129            } => (modifier.clone(), modified.clone(), EdgeType::Modifies, None),
130            Relationship::Defines {
131                definer, defined, ..
132            } => (definer.clone(), defined.clone(), EdgeType::Defines, None),
133        };
134
135        // Only add edge if both nodes exist
136        if self.nodes.contains_key(&from) && self.nodes.contains_key(&to) {
137            self.add_edge(GraphEdge {
138                from,
139                to,
140                weight: edge_type.weight(),
141                edge_type,
142                message_id,
143            });
144        }
145    }
146
147    /// Get node by name
148    pub fn get_node(&self, name: &str) -> Option<&GraphNode> {
149        self.nodes.get(name)
150    }
151
152    /// Get all neighbors of a node
153    pub fn get_neighbors(&self, name: &str) -> Vec<&GraphNode> {
154        let mut neighbors = Vec::new();
155
156        if let Some(edge_indices) = self.adjacency.get(name) {
157            for &idx in edge_indices {
158                if let Some(edge) = self.edges.get(idx) {
159                    let neighbor_name = if edge.from == name {
160                        &edge.to
161                    } else {
162                        &edge.from
163                    };
164                    if let Some(node) = self.nodes.get(neighbor_name) {
165                        neighbors.push(node);
166                    }
167                }
168            }
169        }
170
171        neighbors
172    }
173
174    /// Get edges for a node
175    pub fn get_edges(&self, name: &str) -> Vec<&GraphEdge> {
176        self.adjacency
177            .get(name)
178            .map(|indices| {
179                indices
180                    .iter()
181                    .filter_map(|&idx| self.edges.get(idx))
182                    .collect()
183            })
184            .unwrap_or_default()
185    }
186
187    /// Find shortest path between two entities using BFS
188    pub fn find_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
189        if !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
190            return None;
191        }
192
193        if from == to {
194            return Some(vec![from.to_string()]);
195        }
196
197        let mut visited = HashSet::new();
198        let mut queue = VecDeque::new();
199        let mut parent: HashMap<String, String> = HashMap::new();
200
201        queue.push_back(from.to_string());
202        visited.insert(from.to_string());
203
204        while let Some(current) = queue.pop_front() {
205            for neighbor in self.get_neighbors(&current) {
206                if !visited.contains(&neighbor.entity_name) {
207                    visited.insert(neighbor.entity_name.clone());
208                    parent.insert(neighbor.entity_name.clone(), current.clone());
209
210                    if neighbor.entity_name == to {
211                        // Reconstruct path
212                        let mut path = vec![to.to_string()];
213                        let mut node = to.to_string();
214                        while let Some(p) = parent.get(&node) {
215                            path.push(p.clone());
216                            node = p.clone();
217                        }
218                        path.reverse();
219                        return Some(path);
220                    }
221
222                    queue.push_back(neighbor.entity_name.clone());
223                }
224            }
225        }
226
227        None
228    }
229
230    /// Get all context related to an entity (traverses graph)
231    pub fn get_entity_context(&self, entity: &str, max_depth: usize) -> EntityContext {
232        let mut context = EntityContext {
233            root: entity.to_string(),
234            related_entities: Vec::new(),
235            message_ids: HashSet::new(),
236        };
237
238        if let Some(node) = self.nodes.get(entity) {
239            context.message_ids.extend(node.message_ids.clone());
240        }
241
242        let mut visited = HashSet::new();
243        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
244
245        queue.push_back((entity.to_string(), 0));
246        visited.insert(entity.to_string());
247
248        while let Some((current, depth)) = queue.pop_front() {
249            if depth >= max_depth {
250                continue;
251            }
252
253            for edge in self.get_edges(&current) {
254                let neighbor = if edge.from == current {
255                    &edge.to
256                } else {
257                    &edge.from
258                };
259
260                if !visited.contains(neighbor) {
261                    visited.insert(neighbor.clone());
262
263                    if let Some(node) = self.nodes.get(neighbor) {
264                        context.related_entities.push(RelatedEntity {
265                            name: neighbor.clone(),
266                            entity_type: node.entity_type.clone(),
267                            relationship: edge.edge_type.clone(),
268                            distance: depth + 1,
269                            relevance: edge.weight * (0.8_f32).powi((depth + 1) as i32),
270                        });
271                        context.message_ids.extend(node.message_ids.clone());
272                    }
273
274                    queue.push_back((neighbor.clone(), depth + 1));
275                }
276            }
277        }
278
279        // Sort by relevance
280        context.related_entities.sort_by(|a, b| {
281            b.relevance
282                .partial_cmp(&a.relevance)
283                .unwrap_or(std::cmp::Ordering::Equal)
284        });
285
286        context
287    }
288
289    /// Find entities most relevant to a query (by name matching)
290    pub fn search(&self, query: &str, limit: usize) -> Vec<&GraphNode> {
291        let query_lower = query.to_lowercase();
292        let query_words: HashSet<_> = query_lower.split_whitespace().collect();
293
294        let mut scored: Vec<_> = self
295            .nodes
296            .values()
297            .map(|node| {
298                let name_lower = node.entity_name.to_lowercase();
299                let mut score = 0.0;
300
301                // Exact match
302                if name_lower == query_lower {
303                    score += 1.0;
304                }
305                // Contains query
306                else if name_lower.contains(&query_lower) {
307                    score += 0.7;
308                }
309                // Query contains name
310                else if query_lower.contains(&name_lower) {
311                    score += 0.5;
312                }
313                // Word overlap
314                else {
315                    let name_words: HashSet<_> =
316                        name_lower.split(|c: char| !c.is_alphanumeric()).collect();
317                    let overlap = query_words.intersection(&name_words).count();
318                    score += overlap as f32 * 0.3;
319                }
320
321                // Boost by importance
322                score *= 1.0 + node.importance * 0.5;
323
324                (node, score)
325            })
326            .filter(|(_, score)| *score > 0.0)
327            .collect();
328
329        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
330
331        scored
332            .into_iter()
333            .take(limit)
334            .map(|(node, _)| node)
335            .collect()
336    }
337
338    /// Get graph statistics
339    pub fn stats(&self) -> GraphStats {
340        let mut type_counts = HashMap::new();
341        for node in self.nodes.values() {
342            *type_counts.entry(node.entity_type.as_str()).or_insert(0) += 1;
343        }
344
345        let mut edge_type_counts = HashMap::new();
346        for edge in &self.edges {
347            *edge_type_counts
348                .entry(format!("{:?}", edge.edge_type))
349                .or_insert(0) += 1;
350        }
351
352        GraphStats {
353            node_count: self.nodes.len(),
354            edge_count: self.edges.len(),
355            nodes_by_type: type_counts,
356            edges_by_type: edge_type_counts,
357        }
358    }
359
360    // ============ SEAL Integration Methods ============
361
362    /// Get entities that would be impacted by changes to a given entity.
363    /// Traverses the graph to find dependent entities up to a specified depth.
364    pub fn get_impact_set(&self, entity: &str, depth: usize) -> Vec<ImpactedEntity> {
365        let mut impacts = Vec::new();
366        let mut visited = HashSet::new();
367        let mut queue: VecDeque<(String, usize, f32)> = VecDeque::new();
368
369        if !self.nodes.contains_key(entity) {
370            return impacts;
371        }
372
373        queue.push_back((entity.to_string(), 0, 1.0));
374        visited.insert(entity.to_string());
375
376        while let Some((current, current_depth, current_impact)) = queue.pop_front() {
377            if current_depth >= depth {
378                continue;
379            }
380
381            for edge in self.get_edges(&current) {
382                let neighbor = if edge.from == current {
383                    &edge.to
384                } else {
385                    &edge.from
386                };
387
388                if !visited.contains(neighbor) {
389                    visited.insert(neighbor.clone());
390
391                    // Calculate impact factor based on edge type and weight
392                    let impact_factor = match edge.edge_type {
393                        EdgeType::DependsOn => 0.9,
394                        EdgeType::Contains => 0.8,
395                        EdgeType::Modifies => 0.7,
396                        EdgeType::References => 0.5,
397                        EdgeType::Defines => 0.6,
398                        EdgeType::CoOccurs => 0.3,
399                    };
400
401                    let propagated_impact = current_impact * impact_factor * edge.weight;
402
403                    if let Some(node) = self.nodes.get(neighbor) {
404                        impacts.push(ImpactedEntity {
405                            name: neighbor.clone(),
406                            entity_type: node.entity_type.clone(),
407                            distance: current_depth + 1,
408                            impact_score: propagated_impact,
409                            impact_path: vec![current.clone(), neighbor.clone()],
410                        });
411                    }
412
413                    queue.push_back((neighbor.clone(), current_depth + 1, propagated_impact));
414                }
415            }
416        }
417
418        // Sort by impact score (highest first)
419        impacts.sort_by(|a, b| {
420            b.impact_score
421                .partial_cmp(&a.impact_score)
422                .unwrap_or(std::cmp::Ordering::Equal)
423        });
424
425        impacts
426    }
427
428    /// Find clusters of related entities using connected component analysis
429    pub fn find_clusters(&self) -> Vec<EntityCluster> {
430        let mut clusters = Vec::new();
431        let mut visited = HashSet::new();
432
433        for node_name in self.nodes.keys() {
434            if visited.contains(node_name) {
435                continue;
436            }
437
438            // BFS to find all connected nodes
439            let mut cluster_nodes = Vec::new();
440            let mut queue = VecDeque::new();
441            queue.push_back(node_name.clone());
442            visited.insert(node_name.clone());
443
444            while let Some(current) = queue.pop_front() {
445                if let Some(node) = self.nodes.get(&current) {
446                    cluster_nodes.push(node.clone());
447                }
448
449                for neighbor in self.get_neighbors(&current) {
450                    if !visited.contains(&neighbor.entity_name) {
451                        visited.insert(neighbor.entity_name.clone());
452                        queue.push_back(neighbor.entity_name.clone());
453                    }
454                }
455            }
456
457            if !cluster_nodes.is_empty() {
458                // Calculate cluster metrics
459                let total_importance: f32 = cluster_nodes.iter().map(|n| n.importance).sum();
460                let avg_importance = total_importance / cluster_nodes.len() as f32;
461
462                // Find dominant type
463                let mut type_counts = HashMap::new();
464                for node in &cluster_nodes {
465                    *type_counts.entry(node.entity_type.clone()).or_insert(0) += 1;
466                }
467                let dominant_type = type_counts
468                    .into_iter()
469                    .max_by_key(|(_, count)| *count)
470                    .map(|(t, _)| t);
471
472                clusters.push(EntityCluster {
473                    id: clusters.len(),
474                    nodes: cluster_nodes,
475                    avg_importance,
476                    dominant_type,
477                });
478            }
479        }
480
481        // Sort clusters by size (largest first)
482        clusters.sort_by(|a, b| b.nodes.len().cmp(&a.nodes.len()));
483
484        clusters
485    }
486
487    /// Suggest related entities given a set of entities.
488    /// Uses co-occurrence and relationship analysis.
489    pub fn suggest_related(&self, entities: &[&str]) -> Vec<SuggestedEntity> {
490        let mut scores: HashMap<String, f32> = HashMap::new();
491        let entity_set: HashSet<_> = entities.iter().copied().collect();
492
493        for entity in entities {
494            // Get direct neighbors
495            for neighbor in self.get_neighbors(entity) {
496                if !entity_set.contains(neighbor.entity_name.as_str()) {
497                    *scores.entry(neighbor.entity_name.clone()).or_default() += neighbor.importance;
498                }
499            }
500
501            // Get second-degree neighbors with lower weight
502            for first_neighbor in self.get_neighbors(entity) {
503                if entity_set.contains(first_neighbor.entity_name.as_str()) {
504                    continue;
505                }
506                for second_neighbor in self.get_neighbors(&first_neighbor.entity_name) {
507                    if !entity_set.contains(second_neighbor.entity_name.as_str())
508                        && second_neighbor.entity_name != *entity
509                    {
510                        *scores
511                            .entry(second_neighbor.entity_name.clone())
512                            .or_default() += second_neighbor.importance * 0.5;
513                    }
514                }
515            }
516        }
517
518        // Convert to suggestions
519        let mut suggestions: Vec<_> = scores
520            .into_iter()
521            .filter_map(|(name, score)| {
522                self.nodes.get(&name).map(|node| SuggestedEntity {
523                    name: name.clone(),
524                    entity_type: node.entity_type.clone(),
525                    relevance_score: score,
526                    reason: self.get_suggestion_reason(&name, entities),
527                })
528            })
529            .collect();
530
531        // Sort by relevance
532        suggestions.sort_by(|a, b| {
533            b.relevance_score
534                .partial_cmp(&a.relevance_score)
535                .unwrap_or(std::cmp::Ordering::Equal)
536        });
537
538        suggestions.truncate(10);
539        suggestions
540    }
541
542    /// Get a reason for suggesting an entity
543    fn get_suggestion_reason(&self, suggested: &str, source_entities: &[&str]) -> String {
544        for source in source_entities {
545            // Check direct relationship
546            let edges = self.get_edges(source);
547            for edge in edges {
548                let other = if edge.from == *source {
549                    &edge.to
550                } else {
551                    &edge.from
552                };
553                if other == suggested {
554                    return format!("{:?} by {}", edge.edge_type, source);
555                }
556            }
557        }
558        "Related through graph".to_string()
559    }
560
561    /// Get the most central nodes in the graph (by connectivity)
562    pub fn get_central_nodes(&self, limit: usize) -> Vec<&GraphNode> {
563        let mut centrality: Vec<_> = self
564            .nodes
565            .iter()
566            .map(|(name, node)| {
567                let degree = self.adjacency.get(name).map(|v| v.len()).unwrap_or(0);
568                let weighted_score = node.importance * 0.7 + (degree as f32 / 10.0).min(0.3);
569                (node, weighted_score)
570            })
571            .collect();
572
573        centrality.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
574
575        centrality.into_iter().take(limit).map(|(n, _)| n).collect()
576    }
577
578    // ============ Spectral Graph Methods ============
579
580    /// Convert this graph to a dense weighted adjacency matrix.
581    ///
582    /// Returns `(adjacency_matrix, node_names)` where `node_names[i]` is the
583    /// entity name for row/column `i`. Multi-edges between the same pair are
584    /// summed.
585    #[cfg(feature = "spectral")]
586    fn to_adjacency_matrix(&self) -> (ndarray::Array2<f32>, Vec<String>) {
587        let names: Vec<String> = self.nodes.keys().cloned().collect();
588        let n = names.len();
589        let idx: HashMap<&str, usize> = names
590            .iter()
591            .enumerate()
592            .map(|(i, s)| (s.as_str(), i))
593            .collect();
594
595        let mut adj = ndarray::Array2::<f32>::zeros((n, n));
596        for edge in &self.edges {
597            if let (Some(&i), Some(&j)) = (idx.get(edge.from.as_str()), idx.get(edge.to.as_str())) {
598                adj[[i, j]] += edge.weight;
599                adj[[j, i]] += edge.weight;
600            }
601        }
602
603        (adj, names)
604    }
605
606    /// Find semantic communities within connected components using spectral clustering.
607    ///
608    /// Unlike `find_clusters` which only finds connected components, this method
609    /// discovers tightly-coupled groups *within* a connected component by analyzing
610    /// the graph's spectral properties (Fiedler vector of the Laplacian).
611    ///
612    /// # Arguments
613    ///
614    /// * `k` - Number of clusters to find. If the graph has fewer natural clusters,
615    ///   fewer may be returned.
616    #[cfg(feature = "spectral")]
617    pub fn spectral_clusters(&self, k: usize) -> Vec<EntityCluster> {
618        if self.nodes.is_empty() || k == 0 {
619            return Vec::new();
620        }
621
622        let (adj, names) = self.to_adjacency_matrix();
623        let assignments = match crate::spectral::graph_ops::spectral_cluster(&adj, k) {
624            Some(a) => a,
625            None => return self.find_clusters(), // fall back to connected components
626        };
627
628        // Group nodes by cluster assignment
629        let max_cluster = assignments.iter().copied().max().unwrap_or(0);
630        let mut cluster_nodes: Vec<Vec<GraphNode>> = vec![Vec::new(); max_cluster + 1];
631
632        for (i, &cluster_id) in assignments.iter().enumerate() {
633            if let Some(node) = self.nodes.get(&names[i]) {
634                cluster_nodes[cluster_id].push(node.clone());
635            }
636        }
637
638        // Build EntityCluster for each non-empty group
639        cluster_nodes
640            .into_iter()
641            .enumerate()
642            .filter(|(_, nodes)| !nodes.is_empty())
643            .map(|(id, nodes)| {
644                let avg_importance =
645                    nodes.iter().map(|n| n.importance).sum::<f32>() / nodes.len() as f32;
646                let mut type_counts = HashMap::new();
647                for node in &nodes {
648                    *type_counts.entry(node.entity_type.clone()).or_insert(0) += 1;
649                }
650                let dominant_type = type_counts
651                    .into_iter()
652                    .max_by_key(|(_, c)| *c)
653                    .map(|(t, _)| t);
654
655                EntityCluster {
656                    id,
657                    nodes,
658                    avg_importance,
659                    dominant_type,
660                }
661            })
662            .collect()
663    }
664
665    /// Compute spectral centrality for all nodes.
666    ///
667    /// Returns nodes sorted by spectral centrality (highest first). Nodes with
668    /// high centrality are structural bridges between communities — important
669    /// for understanding cross-cutting concerns in the codebase.
670    ///
671    /// This complements `get_central_nodes` which uses degree centrality.
672    /// Spectral centrality captures *structural position* rather than just
673    /// connection count.
674    #[cfg(feature = "spectral")]
675    pub fn spectral_central_nodes(&self, limit: usize) -> Vec<(&GraphNode, f32)> {
676        if self.nodes.is_empty() {
677            return Vec::new();
678        }
679
680        let (adj, names) = self.to_adjacency_matrix();
681        let scores = crate::spectral::graph_ops::spectral_centrality(&adj);
682
683        scores
684            .into_iter()
685            .filter_map(|(i, score)| self.nodes.get(&names[i]).map(|node| (node, score)))
686            .take(limit)
687            .collect()
688    }
689
690    /// Compute the algebraic connectivity of this graph.
691    ///
692    /// This is the second-smallest eigenvalue of the Laplacian, measuring how
693    /// well-connected the graph is:
694    /// - 0 = disconnected (multiple components)
695    /// - Small = bottleneck exists (near-disconnection)
696    /// - Large = well-connected
697    ///
698    /// Useful for monitoring knowledge graph health as entities accumulate.
699    #[cfg(feature = "spectral")]
700    pub fn connectivity(&self) -> f32 {
701        if self.nodes.len() < 2 {
702            return 0.0;
703        }
704        let (adj, _) = self.to_adjacency_matrix();
705        crate::spectral::graph_ops::algebraic_connectivity(&adj)
706    }
707
708    /// Prune redundant edges using spectral sparsification.
709    ///
710    /// Removes edges that are structurally redundant (many alternative paths
711    /// exist) while preserving edges that are critical for connectivity
712    /// (bridges, bottlenecks).
713    ///
714    /// # Arguments
715    ///
716    /// * `epsilon` - Approximation quality. 0.3 = aggressive pruning (~30% edges
717    ///   removed), 0.1 = conservative (~10% removed). The sparsified graph
718    ///   preserves spectral properties within (1 ± epsilon) of the original.
719    #[cfg(feature = "spectral")]
720    pub fn sparsify(&mut self, epsilon: f32) {
721        if self.nodes.len() < 4 {
722            return; // too small to benefit
723        }
724
725        let (adj, names) = self.to_adjacency_matrix();
726        let sparse_adj = crate::spectral::graph_ops::sparsify(&adj, epsilon);
727
728        let idx: HashMap<&str, usize> = names
729            .iter()
730            .enumerate()
731            .map(|(i, s)| (s.as_str(), i))
732            .collect();
733
734        // Rebuild edges: keep only those present in the sparsified adjacency
735        let mut new_edges = Vec::new();
736        let mut new_adjacency: HashMap<String, Vec<usize>> = HashMap::new();
737
738        // Initialize adjacency lists
739        for name in self.nodes.keys() {
740            new_adjacency.insert(name.clone(), Vec::new());
741        }
742
743        for edge in &self.edges {
744            if let (Some(&i), Some(&j)) = (idx.get(edge.from.as_str()), idx.get(edge.to.as_str())) {
745                if sparse_adj[[i, j]] > 0.0 {
746                    let edge_idx = new_edges.len();
747                    if let Some(adj_list) = new_adjacency.get_mut(&edge.from) {
748                        adj_list.push(edge_idx);
749                    }
750                    if let Some(adj_list) = new_adjacency.get_mut(&edge.to) {
751                        adj_list.push(edge_idx);
752                    }
753                    new_edges.push(edge.clone());
754                }
755            }
756        }
757
758        self.edges = new_edges;
759        self.adjacency = new_adjacency;
760    }
761}
762
763impl brainwires_core::graph::RelationshipGraphT for RelationshipGraph {
764    fn get_node(&self, name: &str) -> Option<&GraphNode> {
765        self.nodes.get(name)
766    }
767
768    fn get_neighbors(&self, name: &str) -> Vec<&GraphNode> {
769        RelationshipGraph::get_neighbors(self, name)
770    }
771
772    fn get_edges(&self, name: &str) -> Vec<&GraphEdge> {
773        RelationshipGraph::get_edges(self, name)
774    }
775
776    fn search(&self, query: &str, limit: usize) -> Vec<&GraphNode> {
777        RelationshipGraph::search(self, query, limit)
778    }
779
780    fn find_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
781        RelationshipGraph::find_path(self, from, to)
782    }
783}
784
785/// Entity impacted by changes to another entity
786#[derive(Debug, Clone)]
787pub struct ImpactedEntity {
788    /// Entity name.
789    pub name: String,
790    /// Entity type.
791    pub entity_type: EntityType,
792    /// Graph distance from the change source.
793    pub distance: usize,
794    /// Computed impact score.
795    pub impact_score: f32,
796    /// Path of entities from source to this entity.
797    pub impact_path: Vec<String>,
798}
799
800/// A cluster of related entities
801#[derive(Debug)]
802pub struct EntityCluster {
803    /// Cluster identifier.
804    pub id: usize,
805    /// Nodes in this cluster.
806    pub nodes: Vec<GraphNode>,
807    /// Average importance of nodes.
808    pub avg_importance: f32,
809    /// Most common entity type in the cluster.
810    pub dominant_type: Option<EntityType>,
811}
812
813/// A suggested related entity
814#[derive(Debug)]
815pub struct SuggestedEntity {
816    /// Entity name.
817    pub name: String,
818    /// Entity type.
819    pub entity_type: EntityType,
820    /// How relevant this suggestion is.
821    pub relevance_score: f32,
822    /// Why this entity was suggested.
823    pub reason: String,
824}
825
826/// Context gathered for an entity
827#[derive(Debug)]
828pub struct EntityContext {
829    /// Root entity name.
830    pub root: String,
831    /// Entities related to the root.
832    pub related_entities: Vec<RelatedEntity>,
833    /// Message IDs relevant to this context.
834    pub message_ids: HashSet<String>,
835}
836
837/// A related entity with relationship info
838#[derive(Debug)]
839pub struct RelatedEntity {
840    /// Entity name.
841    pub name: String,
842    /// Entity type.
843    pub entity_type: EntityType,
844    /// Type of relationship to the root.
845    pub relationship: EdgeType,
846    /// Graph distance from the root.
847    pub distance: usize,
848    /// Relevance score.
849    pub relevance: f32,
850}
851
852/// Graph statistics
853#[derive(Debug)]
854pub struct GraphStats {
855    /// Total number of nodes.
856    pub node_count: usize,
857    /// Total number of edges.
858    pub edge_count: usize,
859    /// Node counts grouped by entity type.
860    pub nodes_by_type: HashMap<&'static str, usize>,
861    /// Edge counts grouped by edge type.
862    pub edges_by_type: HashMap<String, usize>,
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    fn create_test_graph() -> RelationshipGraph {
870        let mut graph = RelationshipGraph::new();
871
872        // Add nodes
873        graph.add_node(GraphNode {
874            entity_name: "src/main.rs".to_string(),
875            entity_type: EntityType::File,
876            message_ids: vec!["msg1".to_string(), "msg2".to_string()],
877            mention_count: 5,
878            importance: 0.8,
879        });
880
881        graph.add_node(GraphNode {
882            entity_name: "main".to_string(),
883            entity_type: EntityType::Function,
884            message_ids: vec!["msg1".to_string()],
885            mention_count: 2,
886            importance: 0.6,
887        });
888
889        graph.add_node(GraphNode {
890            entity_name: "Config".to_string(),
891            entity_type: EntityType::Type,
892            message_ids: vec!["msg2".to_string()],
893            mention_count: 3,
894            importance: 0.7,
895        });
896
897        // Add edges
898        graph.add_edge(GraphEdge {
899            from: "src/main.rs".to_string(),
900            to: "main".to_string(),
901            edge_type: EdgeType::Contains,
902            weight: 0.9,
903            message_id: Some("msg1".to_string()),
904        });
905
906        graph.add_edge(GraphEdge {
907            from: "main".to_string(),
908            to: "Config".to_string(),
909            edge_type: EdgeType::References,
910            weight: 0.6,
911            message_id: Some("msg2".to_string()),
912        });
913
914        graph
915    }
916
917    #[test]
918    fn test_add_and_get_node() {
919        let graph = create_test_graph();
920
921        let node = graph.get_node("src/main.rs");
922        assert!(node.is_some());
923        assert_eq!(node.unwrap().mention_count, 5);
924    }
925
926    #[test]
927    fn test_get_neighbors() {
928        let graph = create_test_graph();
929
930        let neighbors = graph.get_neighbors("src/main.rs");
931        assert_eq!(neighbors.len(), 1);
932        assert_eq!(neighbors[0].entity_name, "main");
933    }
934
935    #[test]
936    fn test_find_path() {
937        let graph = create_test_graph();
938
939        let path = graph.find_path("src/main.rs", "Config");
940        assert!(path.is_some());
941        let path = path.unwrap();
942        assert_eq!(path.len(), 3);
943        assert_eq!(path[0], "src/main.rs");
944        assert_eq!(path[2], "Config");
945    }
946
947    #[test]
948    fn test_get_entity_context() {
949        let graph = create_test_graph();
950
951        let context = graph.get_entity_context("src/main.rs", 2);
952        assert_eq!(context.root, "src/main.rs");
953        assert!(!context.related_entities.is_empty());
954        assert!(!context.message_ids.is_empty());
955    }
956
957    #[test]
958    fn test_search() {
959        let graph = create_test_graph();
960
961        let results = graph.search("main", 5);
962        assert!(!results.is_empty());
963        // Should find both main function and src/main.rs
964        assert!(results.iter().any(|n| n.entity_name == "main"));
965    }
966
967    #[test]
968    fn test_graph_stats() {
969        let graph = create_test_graph();
970
971        let stats = graph.stats();
972        assert_eq!(stats.node_count, 3);
973        assert_eq!(stats.edge_count, 2);
974    }
975
976    #[test]
977    fn test_empty_path() {
978        let graph = create_test_graph();
979
980        // Add disconnected node
981        let mut graph = graph;
982        graph.add_node(GraphNode {
983            entity_name: "isolated".to_string(),
984            entity_type: EntityType::Concept,
985            message_ids: vec![],
986            mention_count: 1,
987            importance: 0.1,
988        });
989
990        let path = graph.find_path("src/main.rs", "isolated");
991        assert!(path.is_none());
992    }
993
994    // ============ SEAL Integration Tests ============
995
996    #[test]
997    fn test_get_impact_set() {
998        let graph = create_test_graph();
999
1000        let impacts = graph.get_impact_set("src/main.rs", 2);
1001        assert!(!impacts.is_empty());
1002
1003        // Should find main and Config
1004        let names: Vec<_> = impacts.iter().map(|i| i.name.as_str()).collect();
1005        assert!(names.contains(&"main"));
1006    }
1007
1008    #[test]
1009    fn test_get_impact_set_empty() {
1010        let graph = create_test_graph();
1011
1012        // Non-existent entity should return empty
1013        let impacts = graph.get_impact_set("nonexistent", 2);
1014        assert!(impacts.is_empty());
1015    }
1016
1017    #[test]
1018    fn test_find_clusters() {
1019        let mut graph = create_test_graph();
1020
1021        // Add a disconnected node to create a second cluster
1022        graph.add_node(GraphNode {
1023            entity_name: "isolated".to_string(),
1024            entity_type: EntityType::Concept,
1025            message_ids: vec![],
1026            mention_count: 1,
1027            importance: 0.1,
1028        });
1029
1030        let clusters = graph.find_clusters();
1031        assert_eq!(clusters.len(), 2);
1032
1033        // First cluster should be the larger connected one
1034        assert_eq!(clusters[0].nodes.len(), 3);
1035        assert_eq!(clusters[1].nodes.len(), 1);
1036    }
1037
1038    #[test]
1039    fn test_suggest_related() {
1040        let graph = create_test_graph();
1041
1042        let suggestions = graph.suggest_related(&["src/main.rs"]);
1043
1044        // Should suggest main (direct neighbor)
1045        let suggested_names: Vec<_> = suggestions.iter().map(|s| s.name.as_str()).collect();
1046        assert!(suggested_names.contains(&"main"));
1047    }
1048
1049    #[test]
1050    fn test_get_central_nodes() {
1051        let graph = create_test_graph();
1052
1053        let central = graph.get_central_nodes(2);
1054        assert!(!central.is_empty());
1055
1056        // main.rs should be among the most central (has edges)
1057        let names: Vec<_> = central.iter().map(|n| n.entity_name.as_str()).collect();
1058        assert!(names.contains(&"src/main.rs") || names.contains(&"main"));
1059    }
1060}