Skip to main content

oxirs_samm/
graph_analytics.rs

1//! Graph-Based Model Analytics using SciRS2-Graph
2//!
3//! This module provides advanced graph analysis for SAMM models using scirs2-graph algorithms.
4//! It analyzes dependency structures, identifies critical components, and detects architectural patterns.
5//!
6//! # Features
7//!
8//! - **Dependency Graph Construction**: Build directed graphs from model dependencies
9//! - **Centrality Analysis**: Identify most important properties and characteristics
10//! - **Community Detection**: Find clusters of related properties
11//! - **Path Analysis**: Shortest paths and critical paths in dependency chains
12//! - **Cycle Detection**: Find circular dependencies
13//! - **Graph Metrics**: Compute standard graph metrics (diameter, density, clustering coefficient)
14//!
15//! # Examples
16//!
17//! ```rust
18//! use oxirs_samm::graph_analytics::ModelGraph;
19//! use oxirs_samm::metamodel::Aspect;
20//!
21//! # fn example(aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
22//! // Build dependency graph
23//! let graph = ModelGraph::from_aspect(aspect)?;
24//!
25//! // Compute centrality metrics
26//! let centrality = graph.compute_centrality();
27//! println!("Most central node: {:?}", centrality.max_node());
28//!
29//! // Find communities
30//! let communities = graph.detect_communities()?;
31//! println!("Found {} communities", communities.len());
32//!
33//! // Detect cycles
34//! let has_cycles = graph.has_cycles()?;
35//! if has_cycles {
36//!     println!("Warning: Circular dependencies detected");
37//! }
38//! # Ok(())
39//! # }
40//! ```
41
42use crate::error::{Result, SammError};
43use crate::metamodel::{Aspect, ModelElement, Property};
44use scirs2_graph::algorithms::shortest_path::dijkstra_path_digraph;
45use scirs2_graph::measures::graph_density_digraph;
46use scirs2_graph::{
47    louvain_communities_result, pagerank, strongly_connected_components, DiGraph, Graph,
48};
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51
52/// Graph representation of a SAMM model
53///
54/// Nodes represent properties and characteristics, edges represent dependencies
55pub struct ModelGraph {
56    /// The underlying directed graph
57    graph: DiGraph<String, f64>,
58    /// List of all nodes (for visualization)
59    nodes: Vec<String>,
60    /// List of all edges as (source, target) pairs (for visualization)
61    edges: Vec<(String, String)>,
62}
63
64impl ModelGraph {
65    /// Build a dependency graph from a SAMM aspect
66    ///
67    /// # Arguments
68    ///
69    /// * `aspect` - The aspect model to analyze
70    ///
71    /// # Returns
72    ///
73    /// A graph representation of the model's dependency structure
74    ///
75    /// # Example
76    ///
77    /// ```rust
78    /// use oxirs_samm::graph_analytics::ModelGraph;
79    /// use oxirs_samm::metamodel::Aspect;
80    ///
81    /// # fn example(aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
82    /// let graph = ModelGraph::from_aspect(aspect)?;
83    /// println!("Graph has {} nodes and {} edges",
84    ///          graph.num_nodes(), graph.num_edges());
85    /// # Ok(())
86    /// # }
87    /// ```
88    pub fn from_aspect(aspect: &Aspect) -> Result<Self> {
89        let mut graph = DiGraph::new();
90        let mut nodes = Vec::new();
91        let mut edges = Vec::new();
92
93        // Extract aspect name from URN
94        let aspect_name = Self::extract_name_from_urn(&aspect.metadata.urn);
95
96        // Add root aspect node
97        graph.add_node(aspect_name.clone());
98        nodes.push(aspect_name.clone());
99
100        // Add property nodes
101        for property in &aspect.properties {
102            let prop_name = Self::extract_name_from_urn(&property.metadata.urn);
103            graph.add_node(prop_name.clone());
104            nodes.push(prop_name.clone());
105
106            // Add edge from aspect to property (weight = 1.0)
107            graph
108                .add_edge(aspect_name.clone(), prop_name.clone(), 1.0)
109                .map_err(|e| SammError::GraphError(format!("Failed to add edge: {}", e)))?;
110            edges.push((aspect_name.clone(), prop_name.clone()));
111
112            // If property has a characteristic, add that relationship
113            if let Some(ref characteristic) = property.characteristic {
114                let char_name = Self::extract_name_from_urn(&characteristic.metadata.urn);
115                graph.add_node(char_name.clone());
116                nodes.push(char_name.clone());
117
118                graph
119                    .add_edge(prop_name.clone(), char_name.clone(), 1.0)
120                    .map_err(|e| SammError::GraphError(format!("Failed to add edge: {}", e)))?;
121                edges.push((prop_name.clone(), char_name));
122            }
123        }
124
125        Ok(Self {
126            graph,
127            nodes,
128            edges,
129        })
130    }
131
132    /// Extract element name from URN (e.g., "urn:samm:test:1.0.0#MyAspect" -> "MyAspect")
133    fn extract_name_from_urn(urn: &str) -> String {
134        urn.split('#').nth(1).unwrap_or(urn).to_string()
135    }
136
137    /// Get the number of nodes in the graph
138    pub fn num_nodes(&self) -> usize {
139        self.nodes.len()
140    }
141
142    /// Get the number of edges in the graph
143    pub fn num_edges(&self) -> usize {
144        self.edges.len()
145    }
146
147    /// Get all nodes in the graph
148    pub fn nodes(&self) -> &[String] {
149        &self.nodes
150    }
151
152    /// Get all edges in the graph
153    pub fn edges(&self) -> &[(String, String)] {
154        &self.edges
155    }
156
157    /// Compute centrality metrics for all nodes
158    ///
159    /// Uses PageRank, betweenness centrality, and closeness centrality to identify
160    /// the most important nodes in the dependency graph.
161    ///
162    /// # Example
163    ///
164    /// ```rust
165    /// use oxirs_samm::graph_analytics::ModelGraph;
166    ///
167    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
168    /// let centrality = graph.compute_centrality();
169    /// println!("Top 5 most central nodes:");
170    /// for (name, score) in centrality.top_nodes(5) {
171    ///     println!("  {}: {:.4}", name, score);
172    /// }
173    /// # Ok(())
174    /// # }
175    /// ```
176    pub fn compute_centrality(&self) -> CentralityMetrics {
177        // Compute PageRank (primary centrality measure for directed graphs)
178        let pagerank_scores = pagerank(&self.graph, 0.85, 1e-6, 100);
179
180        // For directed graphs, we use PageRank as the main centrality measure
181        // Betweenness and closeness centrality are not available for DiGraph in scirs2-graph
182        // So we use PageRank scores as the combined score
183        CentralityMetrics {
184            scores: pagerank_scores.clone(),
185            pagerank: pagerank_scores,
186            betweenness: HashMap::new(), // Not available for DiGraph
187            closeness: HashMap::new(),   // Not available for DiGraph
188        }
189    }
190
191    /// Detect communities (clusters) of related elements
192    ///
193    /// Uses the Louvain algorithm to identify modules or groups of tightly coupled properties.
194    ///
195    /// # Example
196    ///
197    /// ```rust
198    /// use oxirs_samm::graph_analytics::ModelGraph;
199    ///
200    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
201    /// let communities = graph.detect_communities()?;
202    /// println!("Model has {} distinct modules", communities.len());
203    /// for (i, community) in communities.iter().enumerate() {
204    ///     println!("Module {}: {} elements", i, community.members.len());
205    /// }
206    /// # Ok(())
207    /// # }
208    /// ```
209    pub fn detect_communities(&self) -> Result<Vec<Community>> {
210        // Community detection (Louvain) requires undirected graph
211        // For directed graphs, we use strongly connected components as a proxy for communities
212        let sccs = strongly_connected_components(&self.graph);
213
214        Ok(sccs
215            .into_iter()
216            .enumerate()
217            .map(|(id, component)| Community {
218                id,
219                members: component.into_iter().collect(),
220            })
221            .collect())
222    }
223
224    /// Check if the graph has circular dependencies
225    ///
226    /// Circular dependencies indicate potential design issues and should be avoided.
227    ///
228    /// # Example
229    ///
230    /// ```rust
231    /// use oxirs_samm::graph_analytics::ModelGraph;
232    ///
233    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
234    /// if graph.has_cycles()? {
235    ///     eprintln!("Warning: Circular dependencies detected!");
236    /// }
237    /// # Ok(())
238    /// # }
239    /// ```
240    pub fn has_cycles(&self) -> Result<bool> {
241        // A directed graph has cycles if it has more than one strongly connected component
242        // or if any SCC has more than one node
243        let sccs = strongly_connected_components(&self.graph);
244
245        // If there's more than one node in any SCC, there's a cycle
246        Ok(sccs.iter().any(|scc| scc.len() > 1))
247    }
248
249    /// Compute shortest path between two elements
250    ///
251    /// # Arguments
252    ///
253    /// * `from` - Source element name
254    /// * `to` - Target element name
255    ///
256    /// # Returns
257    ///
258    /// The path from source to target, or None if no path exists
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use oxirs_samm::graph_analytics::ModelGraph;
264    ///
265    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
266    /// if let Some(path) = graph.shortest_path("Property1", "Property2")? {
267    ///     println!("Path: {}", path.join(" -> "));
268    /// } else {
269    ///     println!("No dependency path found");
270    /// }
271    /// # Ok(())
272    /// # }
273    /// ```
274    pub fn shortest_path(&self, from: &str, to: &str) -> Result<Option<Vec<String>>> {
275        match dijkstra_path_digraph(&self.graph, &from.to_string(), &to.to_string()) {
276            Ok(Some(path_data)) => Ok(Some(path_data.nodes)),
277            Ok(None) => Ok(None),
278            Err(e) => Err(SammError::GraphError(format!("Failed to find path: {}", e))),
279        }
280    }
281
282    /// Compute comprehensive graph metrics
283    ///
284    /// Returns metrics like diameter, density, clustering coefficient, etc.
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use oxirs_samm::graph_analytics::ModelGraph;
290    ///
291    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
292    /// let metrics = graph.compute_metrics()?;
293    /// println!("Graph Metrics:");
294    /// println!("  Nodes: {}", metrics.num_nodes);
295    /// println!("  Edges: {}", metrics.num_edges);
296    /// println!("  Density: {:.4}", metrics.density);
297    /// # Ok(())
298    /// # }
299    /// ```
300    pub fn compute_metrics(&self) -> Result<GraphMetrics> {
301        let n = self.num_nodes();
302        let m = self.num_edges();
303
304        // Compute density using scirs2-graph (DiGraph version)
305        let density = graph_density_digraph(&self.graph)
306            .map_err(|e| SammError::GraphError(format!("Failed to compute density: {}", e)))?;
307
308        // Diameter is not available for DiGraph in scirs2-graph
309        // We set it to 0 as a placeholder
310        let diameter_value = 0.0;
311
312        Ok(GraphMetrics {
313            num_nodes: n,
314            num_edges: m,
315            diameter: diameter_value,
316            density,
317        })
318    }
319
320    /// Get strongly connected components
321    ///
322    /// Returns groups of nodes where each node is reachable from every other node in the group.
323    ///
324    /// # Example
325    ///
326    /// ```rust
327    /// use oxirs_samm::graph_analytics::ModelGraph;
328    ///
329    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
330    /// let sccs = graph.strongly_connected_components()?;
331    /// println!("Found {} strongly connected components", sccs.len());
332    /// # Ok(())
333    /// # }
334    /// ```
335    pub fn strongly_connected_components(&self) -> Result<Vec<Vec<String>>> {
336        let sccs = strongly_connected_components(&self.graph);
337
338        Ok(sccs
339            .into_iter()
340            .map(|component| component.into_iter().collect())
341            .collect())
342    }
343
344    /// Analyze the impact of changing or removing a node
345    ///
346    /// This performs a dependency impact analysis to identify all nodes that would be
347    /// affected if the given node is modified or removed. It computes both direct
348    /// dependents and transitive dependents, along with a risk assessment.
349    ///
350    /// # Arguments
351    ///
352    /// * `node_name` - The name of the node to analyze
353    ///
354    /// # Returns
355    ///
356    /// An `ImpactAnalysis` struct containing:
357    /// - Direct dependents (nodes with edges from the source node)
358    /// - All transitive dependents (reachable via dependency chains)
359    /// - Impact score (percentage of graph affected)
360    /// - Risk level classification
361    ///
362    /// # Example
363    ///
364    /// ```rust
365    /// use oxirs_samm::graph_analytics::ModelGraph;
366    /// use oxirs_samm::metamodel::Aspect;
367    ///
368    /// # fn example(aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
369    /// let graph = ModelGraph::from_aspect(aspect)?;
370    /// let impact = graph.analyze_impact("PropertyName")?;
371    ///
372    /// println!("Changing {} would affect {} nodes",
373    ///          impact.source_node, impact.all_dependents.len());
374    /// println!("Risk level: {:?}", impact.risk_level);
375    /// # Ok(())
376    /// # }
377    /// ```
378    pub fn analyze_impact(&self, node_name: &str) -> Result<ImpactAnalysis> {
379        use std::collections::{HashSet, VecDeque};
380
381        // Find all nodes reachable from the source node (forward search)
382        let mut all_dependents = HashSet::new();
383        let mut direct_dependents = Vec::new();
384        let mut queue = VecDeque::new();
385        let mut visited = HashSet::new();
386
387        queue.push_back(node_name.to_string());
388        visited.insert(node_name.to_string());
389
390        // Perform BFS to find all dependents
391        while let Some(current) = queue.pop_front() {
392            // Find all outgoing edges from current node
393            for (source, target) in &self.edges {
394                if source == &current && !visited.contains(target) {
395                    // Direct dependent of the source node
396                    if source == node_name {
397                        direct_dependents.push(target.clone());
398                    }
399
400                    all_dependents.insert(target.clone());
401                    visited.insert(target.clone());
402                    queue.push_back(target.clone());
403                }
404            }
405        }
406
407        // Remove the source node itself from dependents
408        all_dependents.remove(node_name);
409
410        // Calculate impact score
411        let total_nodes = self.num_nodes() as f64;
412        let affected_nodes = all_dependents.len() as f64;
413        let impact_score = if total_nodes > 1.0 {
414            affected_nodes / (total_nodes - 1.0) // Exclude source node
415        } else {
416            0.0
417        };
418
419        // Determine risk level
420        let risk_level = RiskLevel::from_impact_score(impact_score);
421
422        Ok(ImpactAnalysis {
423            source_node: node_name.to_string(),
424            direct_dependents,
425            all_dependents: all_dependents.into_iter().collect(),
426            impact_score,
427            risk_level,
428        })
429    }
430
431    /// Suggest ways to break circular dependencies
432    ///
433    /// Analyzes detected cycles and provides suggestions for breaking them,
434    /// including which edges to remove and why.
435    ///
436    /// # Returns
437    ///
438    /// A vector of suggestions for breaking each cycle found in the graph
439    ///
440    /// # Example
441    ///
442    /// ```rust
443    /// use oxirs_samm::graph_analytics::ModelGraph;
444    /// use oxirs_samm::metamodel::Aspect;
445    ///
446    /// # fn example(aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
447    /// let graph = ModelGraph::from_aspect(aspect)?;
448    ///
449    /// if graph.has_cycles()? {
450    ///     let suggestions = graph.suggest_cycle_breaks()?;
451    ///     for suggestion in suggestions {
452    ///         println!("Remove edge: {:?}", suggestion.edge_to_remove);
453    ///         println!("Reason: {}", suggestion.reason);
454    ///     }
455    /// }
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub fn suggest_cycle_breaks(&self) -> Result<Vec<CycleBreakSuggestion>> {
460        let sccs = self.strongly_connected_components()?;
461        let mut suggestions = Vec::new();
462
463        // Find SCCs with more than one node (cycles)
464        for scc in sccs {
465            if scc.len() > 1 {
466                // Analyze edges within this SCC
467                let scc_edges: Vec<_> = self
468                    .edges
469                    .iter()
470                    .filter(|(source, target)| scc.contains(source) && scc.contains(target))
471                    .collect();
472
473                // Suggest breaking the edge with the least impact
474                for edge in scc_edges.iter().take(3) {
475                    // Suggest top 3 edges
476                    let (source, target) = edge;
477
478                    let reason = format!(
479                        "Breaking the dependency from '{}' to '{}' would eliminate the circular reference",
480                        source, target
481                    );
482
483                    let impact = format!(
484                        "Removing this edge would require '{}' to no longer depend on '{}'",
485                        source, target
486                    );
487
488                    let alternatives = vec![
489                        format!(
490                            "Introduce an interface or abstraction between '{}' and '{}'",
491                            source, target
492                        ),
493                        format!("Move shared functionality to a separate component"),
494                        format!("Use dependency injection or event-based communication"),
495                    ];
496
497                    suggestions.push(CycleBreakSuggestion {
498                        edge_to_remove: (source.to_string(), target.to_string()),
499                        reason,
500                        impact,
501                        alternatives,
502                    });
503                }
504            }
505        }
506
507        Ok(suggestions)
508    }
509
510    /// Compare this graph with another graph
511    ///
512    /// Performs structural comparison between two model graphs,
513    /// identifying added/removed nodes and edges, and computing similarity metrics.
514    ///
515    /// # Arguments
516    ///
517    /// * `other` - The graph to compare with
518    ///
519    /// # Returns
520    ///
521    /// A `GraphComparison` struct containing:
522    /// - Lists of added/removed nodes and edges
523    /// - Similarity score (0.0-1.0, where 1.0 means identical)
524    /// - Change magnitude classification
525    ///
526    /// # Example
527    ///
528    /// ```rust
529    /// use oxirs_samm::graph_analytics::ModelGraph;
530    /// use oxirs_samm::metamodel::Aspect;
531    ///
532    /// # fn example(old_aspect: &Aspect, new_aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
533    /// let old_graph = ModelGraph::from_aspect(old_aspect)?;
534    /// let new_graph = ModelGraph::from_aspect(new_aspect)?;
535    ///
536    /// let comparison = old_graph.compare(&new_graph)?;
537    /// println!("Similarity: {:.2}%", comparison.similarity_score * 100.0);
538    /// println!("Change magnitude: {:?}", comparison.change_magnitude);
539    /// println!("Added {} nodes, removed {} nodes",
540    ///          comparison.added_nodes.len(),
541    ///          comparison.removed_nodes.len());
542    /// # Ok(())
543    /// # }
544    /// ```
545    pub fn compare(&self, other: &ModelGraph) -> Result<GraphComparison> {
546        use std::collections::HashSet;
547
548        // Convert to sets for efficient comparison
549        let old_nodes: HashSet<_> = self.nodes.iter().cloned().collect();
550        let new_nodes: HashSet<_> = other.nodes.iter().cloned().collect();
551
552        let old_edges: HashSet<_> = self.edges.iter().cloned().collect();
553        let new_edges: HashSet<_> = other.edges.iter().cloned().collect();
554
555        // Find differences
556        let added_nodes: Vec<_> = new_nodes.difference(&old_nodes).cloned().collect();
557        let removed_nodes: Vec<_> = old_nodes.difference(&new_nodes).cloned().collect();
558
559        let added_edges: Vec<_> = new_edges.difference(&old_edges).cloned().collect();
560        let removed_edges: Vec<_> = old_edges.difference(&new_edges).cloned().collect();
561
562        // Calculate similarity using Jaccard index
563        let total_nodes = old_nodes.union(&new_nodes).count();
564        let common_nodes = old_nodes.intersection(&new_nodes).count();
565        let node_similarity = if total_nodes > 0 {
566            common_nodes as f64 / total_nodes as f64
567        } else {
568            1.0
569        };
570
571        let total_edges = old_edges.union(&new_edges).count();
572        let common_edges = old_edges.intersection(&new_edges).count();
573        let edge_similarity = if total_edges > 0 {
574            common_edges as f64 / total_edges as f64
575        } else {
576            1.0
577        };
578
579        // Combined similarity (weighted average)
580        let similarity_score = (node_similarity * 0.6) + (edge_similarity * 0.4);
581
582        // Determine change magnitude
583        let change_magnitude = ChangeMagnitude::from_similarity_score(similarity_score);
584
585        Ok(GraphComparison {
586            added_nodes,
587            removed_nodes,
588            added_edges,
589            removed_edges,
590            similarity_score,
591            change_magnitude,
592        })
593    }
594}
595
596/// Centrality metrics for model elements
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct CentralityMetrics {
599    /// Combined centrality scores
600    pub scores: HashMap<String, f64>,
601    /// PageRank scores
602    pub pagerank: HashMap<String, f64>,
603    /// Betweenness centrality
604    pub betweenness: HashMap<String, f64>,
605    /// Closeness centrality
606    pub closeness: HashMap<String, f64>,
607}
608
609impl CentralityMetrics {
610    /// Get the node with maximum centrality
611    pub fn max_node(&self) -> Option<(&String, f64)> {
612        self.scores
613            .iter()
614            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
615            .map(|(name, score)| (name, *score))
616    }
617
618    /// Get top N nodes by centrality
619    pub fn top_nodes(&self, n: usize) -> Vec<(&String, f64)> {
620        let mut sorted: Vec<_> = self
621            .scores
622            .iter()
623            .map(|(name, score)| (name, *score))
624            .collect();
625        sorted.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
626        sorted.into_iter().take(n).collect()
627    }
628}
629
630/// A community (cluster) of related model elements
631#[derive(Debug, Clone, Serialize, Deserialize)]
632pub struct Community {
633    /// Community identifier
634    pub id: usize,
635    /// Member element names
636    pub members: Vec<String>,
637}
638
639/// A circular dependency cycle
640#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct Cycle {
642    /// The path forming the cycle
643    pub path: Vec<String>,
644}
645
646impl Cycle {
647    /// Get the length of the cycle
648    pub fn len(&self) -> usize {
649        self.path.len()
650    }
651
652    /// Check if the cycle is empty
653    pub fn is_empty(&self) -> bool {
654        self.path.is_empty()
655    }
656}
657
658/// Comprehensive graph metrics
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct GraphMetrics {
661    /// Number of nodes
662    pub num_nodes: usize,
663    /// Number of edges
664    pub num_edges: usize,
665    /// Graph diameter (longest shortest path) - 0.0 for DiGraph (not available)
666    pub diameter: f64,
667    /// Graph density (0-1)
668    pub density: f64,
669}
670
671/// Impact analysis result showing affected nodes
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct ImpactAnalysis {
674    /// The node being analyzed
675    pub source_node: String,
676    /// Direct dependents (nodes that directly depend on the source)
677    pub direct_dependents: Vec<String>,
678    /// All transitive dependents (nodes that depend directly or indirectly)
679    pub all_dependents: Vec<String>,
680    /// Impact score (0.0-1.0) - percentage of graph affected
681    pub impact_score: f64,
682    /// Risk level based on impact
683    pub risk_level: RiskLevel,
684}
685
686/// Risk level for change impact
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
688pub enum RiskLevel {
689    /// Low risk (<10% of nodes affected)
690    Low,
691    /// Medium risk (10-30% of nodes affected)
692    Medium,
693    /// High risk (30-50% of nodes affected)
694    High,
695    /// Critical risk (>50% of nodes affected)
696    Critical,
697}
698
699impl RiskLevel {
700    /// Determine risk level from impact score
701    pub fn from_impact_score(score: f64) -> Self {
702        if score < 0.1 {
703            RiskLevel::Low
704        } else if score < 0.3 {
705            RiskLevel::Medium
706        } else if score < 0.5 {
707            RiskLevel::High
708        } else {
709            RiskLevel::Critical
710        }
711    }
712}
713
714/// Suggestion for breaking a circular dependency
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct CycleBreakSuggestion {
717    /// The edge to remove (source, target)
718    pub edge_to_remove: (String, String),
719    /// Reason for this suggestion
720    pub reason: String,
721    /// Impact of removing this edge
722    pub impact: String,
723    /// Alternative approaches
724    pub alternatives: Vec<String>,
725}
726
727/// Graph comparison result
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct GraphComparison {
730    /// Nodes added in the new graph
731    pub added_nodes: Vec<String>,
732    /// Nodes removed from the old graph
733    pub removed_nodes: Vec<String>,
734    /// Edges added in the new graph
735    pub added_edges: Vec<(String, String)>,
736    /// Edges removed from the old graph
737    pub removed_edges: Vec<(String, String)>,
738    /// Overall similarity score (0.0-1.0)
739    pub similarity_score: f64,
740    /// Structural change magnitude
741    pub change_magnitude: ChangeMagnitude,
742}
743
744/// Magnitude of structural changes
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
746pub enum ChangeMagnitude {
747    /// Minimal changes (<5%)
748    Minimal,
749    /// Minor changes (5-15%)
750    Minor,
751    /// Moderate changes (15-30%)
752    Moderate,
753    /// Major changes (30-50%)
754    Major,
755    /// Extensive changes (>50%)
756    Extensive,
757}
758
759impl ChangeMagnitude {
760    /// Determine change magnitude from similarity score
761    pub fn from_similarity_score(score: f64) -> Self {
762        if score > 0.95 {
763            ChangeMagnitude::Minimal
764        } else if score > 0.85 {
765            ChangeMagnitude::Minor
766        } else if score > 0.70 {
767            ChangeMagnitude::Moderate
768        } else if score > 0.50 {
769            ChangeMagnitude::Major
770        } else {
771            ChangeMagnitude::Extensive
772        }
773    }
774}
775
776// Visualization module for graph rendering
777pub mod visualization;
778pub use visualization::{ColorScheme, VisualizationStyle};
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use crate::metamodel::{Aspect, Characteristic, CharacteristicKind, Property};
784    use std::collections::HashMap;
785
786    fn create_test_aspect() -> Aspect {
787        let mut aspect = Aspect::new("urn:samm:test:1.0.0#TestAspect".to_string());
788
789        // Add 3 properties
790        for i in 1..=3 {
791            let characteristic = Characteristic {
792                metadata: crate::metamodel::ElementMetadata::new(format!(
793                    "urn:samm:test:1.0.0#Char{}",
794                    i
795                )),
796                data_type: Some("string".to_string()),
797                kind: CharacteristicKind::Trait,
798                constraints: vec![],
799            };
800
801            let property = Property::new(format!("urn:samm:test:1.0.0#Property{}", i))
802                .with_characteristic(characteristic);
803
804            aspect.add_property(property);
805        }
806
807        aspect
808    }
809
810    #[test]
811    fn test_graph_construction() {
812        let aspect = create_test_aspect();
813        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
814
815        // 1 aspect + 3 properties + 3 characteristics = 7 nodes
816        assert_eq!(graph.num_nodes(), 7);
817
818        // 3 edges (aspect->properties) + 3 edges (properties->characteristics) = 6 edges
819        assert_eq!(graph.num_edges(), 6);
820    }
821
822    #[test]
823    fn test_centrality_computation() {
824        let aspect = create_test_aspect();
825        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
826
827        let centrality = graph.compute_centrality();
828
829        // Should have scores for all nodes
830        assert_eq!(centrality.scores.len(), 7);
831
832        // Get top node
833        let (top_node, score) = centrality.max_node().expect("operation should succeed");
834        // Just check that there is a top node with a positive score
835        assert!(!top_node.is_empty());
836        assert!(score > 0.0);
837    }
838
839    #[test]
840    fn test_community_detection() {
841        let aspect = create_test_aspect();
842        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
843
844        let communities = graph
845            .detect_communities()
846            .expect("detection should succeed");
847
848        // Should have at least 1 community
849        assert!(!communities.is_empty());
850
851        // Total members across all communities should equal number of nodes
852        let total_members: usize = communities.iter().map(|c| c.members.len()).sum();
853        assert_eq!(total_members, graph.num_nodes());
854    }
855
856    #[test]
857    fn test_cycle_detection() {
858        let aspect = create_test_aspect();
859        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
860
861        let has_cycles = graph.has_cycles().expect("operation should succeed");
862
863        // Simple tree structure should have no cycles
864        assert!(!has_cycles);
865    }
866
867    #[test]
868    fn test_graph_metrics() {
869        let aspect = create_test_aspect();
870        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
871
872        let metrics = graph.compute_metrics().expect("operation should succeed");
873
874        assert_eq!(metrics.num_nodes, 7);
875        assert_eq!(metrics.num_edges, 6);
876        assert!(metrics.density > 0.0);
877        assert!(metrics.density <= 1.0);
878    }
879
880    #[test]
881    fn test_shortest_path() {
882        let aspect = create_test_aspect();
883        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
884
885        // Path from aspect to property should exist
886        let path = graph
887            .shortest_path("TestAspect", "Property1")
888            .expect("operation should succeed")
889            .expect("operation should succeed");
890
891        assert_eq!(path.len(), 2); // Direct edge
892        assert_eq!(path[0], "TestAspect");
893        assert_eq!(path[1], "Property1");
894    }
895
896    #[test]
897    fn test_strongly_connected_components() {
898        let aspect = create_test_aspect();
899        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
900
901        let sccs = graph
902            .strongly_connected_components()
903            .expect("operation should succeed");
904
905        // Tree structure should have each node as its own SCC
906        assert_eq!(sccs.len(), 7);
907    }
908
909    #[test]
910    fn test_impact_analysis() {
911        let aspect = create_test_aspect();
912        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
913
914        // Analyze impact of TestAspect node
915        let impact = graph
916            .analyze_impact("TestAspect")
917            .expect("analysis should succeed");
918
919        assert_eq!(impact.source_node, "TestAspect");
920        // Should have 3 direct dependents (the properties)
921        assert_eq!(impact.direct_dependents.len(), 3);
922        // Should have 6 total dependents (3 properties + 3 characteristics)
923        assert_eq!(impact.all_dependents.len(), 6);
924        // Impact score should be 6/6 = 1.0 (all other nodes affected)
925        assert!((impact.impact_score - 1.0).abs() < 0.01);
926        // Should be critical risk
927        assert_eq!(impact.risk_level, RiskLevel::Critical);
928    }
929
930    #[test]
931    fn test_impact_analysis_leaf_node() {
932        let aspect = create_test_aspect();
933        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
934
935        // Analyze impact of a characteristic (leaf node)
936        let impact = graph
937            .analyze_impact("Char1")
938            .expect("analysis should succeed");
939
940        assert_eq!(impact.source_node, "Char1");
941        // Leaf node should have no dependents
942        assert_eq!(impact.direct_dependents.len(), 0);
943        assert_eq!(impact.all_dependents.len(), 0);
944        // Impact score should be 0.0
945        assert_eq!(impact.impact_score, 0.0);
946        // Should be low risk
947        assert_eq!(impact.risk_level, RiskLevel::Low);
948    }
949
950    #[test]
951    fn test_suggest_cycle_breaks_no_cycles() {
952        let aspect = create_test_aspect();
953        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
954
955        let suggestions = graph
956            .suggest_cycle_breaks()
957            .expect("operation should succeed");
958
959        // No cycles in a tree structure
960        assert!(suggestions.is_empty());
961    }
962
963    #[test]
964    fn test_graph_comparison_identical() {
965        let aspect1 = create_test_aspect();
966        let aspect2 = create_test_aspect();
967
968        let graph1 = ModelGraph::from_aspect(&aspect1).expect("conversion should succeed");
969        let graph2 = ModelGraph::from_aspect(&aspect2).expect("conversion should succeed");
970
971        let comparison = graph1.compare(&graph2).expect("comparison should succeed");
972
973        // Identical graphs
974        assert!(comparison.added_nodes.is_empty());
975        assert!(comparison.removed_nodes.is_empty());
976        assert!(comparison.added_edges.is_empty());
977        assert!(comparison.removed_edges.is_empty());
978        assert_eq!(comparison.similarity_score, 1.0);
979        assert_eq!(comparison.change_magnitude, ChangeMagnitude::Minimal);
980    }
981
982    #[test]
983    fn test_graph_comparison_with_changes() {
984        let aspect1 = create_test_aspect();
985        let mut aspect2 = create_test_aspect();
986
987        // Add a new property to aspect2
988        let characteristic = Characteristic {
989            metadata: crate::metamodel::ElementMetadata::new(
990                "urn:samm:test:1.0.0#NewChar".to_string(),
991            ),
992            data_type: Some("string".to_string()),
993            kind: CharacteristicKind::Trait,
994            constraints: vec![],
995        };
996
997        let property = Property::new("urn:samm:test:1.0.0#NewProperty".to_string())
998            .with_characteristic(characteristic);
999
1000        aspect2.add_property(property);
1001
1002        let graph1 = ModelGraph::from_aspect(&aspect1).expect("conversion should succeed");
1003        let graph2 = ModelGraph::from_aspect(&aspect2).expect("conversion should succeed");
1004
1005        let comparison = graph1.compare(&graph2).expect("comparison should succeed");
1006
1007        // Should have 2 added nodes (1 property + 1 characteristic)
1008        assert_eq!(comparison.added_nodes.len(), 2);
1009        assert!(comparison.added_nodes.contains(&"NewProperty".to_string()));
1010        assert!(comparison.added_nodes.contains(&"NewChar".to_string()));
1011
1012        // Should have 2 added edges
1013        assert_eq!(comparison.added_edges.len(), 2);
1014
1015        // Similarity should be less than 1.0
1016        assert!(comparison.similarity_score < 1.0);
1017        assert!(comparison.similarity_score > 0.0);
1018    }
1019
1020    #[test]
1021    fn test_risk_level_classification() {
1022        assert_eq!(RiskLevel::from_impact_score(0.05), RiskLevel::Low);
1023        assert_eq!(RiskLevel::from_impact_score(0.15), RiskLevel::Medium);
1024        assert_eq!(RiskLevel::from_impact_score(0.35), RiskLevel::High);
1025        assert_eq!(RiskLevel::from_impact_score(0.75), RiskLevel::Critical);
1026    }
1027
1028    #[test]
1029    fn test_change_magnitude_classification() {
1030        assert_eq!(
1031            ChangeMagnitude::from_similarity_score(0.98),
1032            ChangeMagnitude::Minimal
1033        );
1034        assert_eq!(
1035            ChangeMagnitude::from_similarity_score(0.90),
1036            ChangeMagnitude::Minor
1037        );
1038        assert_eq!(
1039            ChangeMagnitude::from_similarity_score(0.75),
1040            ChangeMagnitude::Moderate
1041        );
1042        assert_eq!(
1043            ChangeMagnitude::from_similarity_score(0.55),
1044            ChangeMagnitude::Major
1045        );
1046        assert_eq!(
1047            ChangeMagnitude::from_similarity_score(0.30),
1048            ChangeMagnitude::Extensive
1049        );
1050    }
1051}