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 == ¤t && !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}