Skip to main content

arrow_graph/streaming/
detection.rs

1use crate::error::Result;
2use crate::streaming::incremental::{IncrementalGraphProcessor, UpdateResult};
3use arrow::array::Array;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6/// Graph change detection algorithms for anomaly detection and community drift
7/// These algorithms monitor graph evolution and detect significant structural changes
8#[derive(Debug, Clone)]
9pub struct GraphChangeDetector {
10    // Community tracking
11    historical_communities: VecDeque<HashMap<String, String>>, // Rolling window of community assignments
12    community_stability_threshold: f64, // Threshold for detecting community drift
13    max_history_length: usize,
14    
15    // Anomaly detection
16    baseline_metrics: GraphMetrics,
17    anomaly_thresholds: AnomalyThresholds,
18    anomaly_history: VecDeque<AnomalyEvent>,
19    
20    // Change tracking
21    significant_changes: Vec<GraphChange>,
22    #[allow(dead_code)]
23    change_sensitivity: f64,
24}
25
26/// Key graph metrics for anomaly detection
27#[derive(Debug, Clone)]
28pub struct GraphMetrics {
29    pub node_count: usize,
30    pub edge_count: usize,
31    pub density: f64,
32    pub average_degree: f64,
33    pub clustering_coefficient: f64,
34    pub largest_component_size: usize,
35    pub component_count: usize,
36}
37
38/// Thresholds for detecting anomalous changes
39#[derive(Debug, Clone)]
40pub struct AnomalyThresholds {
41    pub density_change: f64,        // Threshold for density change
42    pub degree_change: f64,         // Threshold for average degree change
43    pub clustering_change: f64,     // Threshold for clustering coefficient change
44    pub component_change: f64,      // Threshold for component structure change
45    pub edge_burst_rate: f64,       // Threshold for edge addition rate
46    pub node_burst_rate: f64,       // Threshold for node addition rate
47}
48
49/// Detected anomaly event
50#[derive(Debug, Clone)]
51pub struct AnomalyEvent {
52    pub timestamp: u64,
53    pub anomaly_type: AnomalyType,
54    pub severity: f64,  // 0.0 to 1.0
55    pub description: String,
56    pub affected_nodes: Vec<String>,
57}
58
59/// Types of anomalies that can be detected
60#[derive(Debug, Clone, PartialEq)]
61pub enum AnomalyType {
62    DensitySpike,       // Sudden increase in edge density
63    DensityDrop,        // Sudden decrease in edge density
64    DegreeBurst,        // Unusual degree distribution changes
65    CommunityShift,     // Significant community structure changes
66    ComponentMerge,     // Connected components merging
67    ComponentSplit,     // Connected components splitting
68    EdgeBurst,          // Rapid edge additions
69    NodeBurst,          // Rapid node additions
70    IsolatedNodes,      // Nodes becoming isolated
71    HubFormation,       // New high-degree nodes appearing
72}
73
74/// Detected significant graph change
75#[derive(Debug, Clone)]
76pub struct GraphChange {
77    pub change_type: ChangeType,
78    pub magnitude: f64,
79    pub affected_nodes: Vec<String>,
80    pub timestamp: u64,
81}
82
83/// Types of significant structural changes
84#[derive(Debug, Clone, PartialEq)]
85pub enum ChangeType {
86    CommunityMerge,
87    CommunitySplit,
88    CommunityDrift,
89    TopologyChange,
90    ScaleChange,
91}
92
93impl Default for AnomalyThresholds {
94    fn default() -> Self {
95        Self {
96            density_change: 0.1,      // 10% change in density
97            degree_change: 0.15,      // 15% change in average degree
98            clustering_change: 0.2,   // 20% change in clustering
99            component_change: 0.05,   // 5% change in component structure
100            edge_burst_rate: 10.0,    // 10+ edges per update
101            node_burst_rate: 5.0,     // 5+ nodes per update
102        }
103    }
104}
105
106impl GraphChangeDetector {
107    pub fn new(max_history: usize, change_sensitivity: f64) -> Self {
108        Self {
109            historical_communities: VecDeque::new(),
110            community_stability_threshold: 0.8, // 80% stability required
111            max_history_length: max_history,
112            baseline_metrics: GraphMetrics {
113                node_count: 0,
114                edge_count: 0,
115                density: 0.0,
116                average_degree: 0.0,
117                clustering_coefficient: 0.0,
118                largest_component_size: 0,
119                component_count: 0,
120            },
121            anomaly_thresholds: AnomalyThresholds::default(),
122            anomaly_history: VecDeque::new(),
123            significant_changes: Vec::new(),
124            change_sensitivity,
125        }
126    }
127
128    /// Default change detector
129    pub fn default() -> Self {
130        Self::new(10, 0.1) // Keep 10 snapshots, 10% change sensitivity
131    }
132
133    /// Initialize the detector with current graph state
134    pub fn initialize(&mut self, processor: &IncrementalGraphProcessor) -> Result<()> {
135        self.baseline_metrics = self.compute_graph_metrics(processor)?;
136        
137        // Initialize community detection
138        let communities = self.detect_communities(processor)?;
139        self.historical_communities.push_back(communities);
140        
141        Ok(())
142    }
143
144    /// Update detector with new graph changes
145    pub fn update(&mut self, processor: &IncrementalGraphProcessor, changes: &UpdateResult) -> Result<Vec<AnomalyEvent>> {
146        let mut detected_anomalies = Vec::new();
147        let current_time = std::time::SystemTime::now()
148            .duration_since(std::time::UNIX_EPOCH)
149            .unwrap()
150            .as_secs();
151
152        // Compute current metrics
153        let current_metrics = self.compute_graph_metrics(processor)?;
154
155        // Detect anomalies based on metric changes
156        detected_anomalies.extend(self.detect_metric_anomalies(&current_metrics, current_time)?);
157
158        // Detect burst anomalies
159        detected_anomalies.extend(self.detect_burst_anomalies(changes, current_time)?);
160
161        // Detect community changes
162        let current_communities = self.detect_communities(processor)?;
163        detected_anomalies.extend(self.detect_community_anomalies(&current_communities, current_time)?);
164
165        // Update history
166        self.update_history(current_communities, &detected_anomalies);
167        self.baseline_metrics = current_metrics;
168
169        // Store significant changes
170        if !detected_anomalies.is_empty() {
171            let change = GraphChange {
172                change_type: self.classify_change(&detected_anomalies),
173                magnitude: self.calculate_change_magnitude(&detected_anomalies),
174                affected_nodes: self.extract_affected_nodes(&detected_anomalies),
175                timestamp: current_time,
176            };
177            self.significant_changes.push(change);
178        }
179
180        Ok(detected_anomalies)
181    }
182
183    /// Compute key graph metrics
184    fn compute_graph_metrics(&self, processor: &IncrementalGraphProcessor) -> Result<GraphMetrics> {
185        let graph = processor.graph();
186        let node_count = graph.node_count();
187        let edge_count = graph.edge_count();
188        
189        let density = if node_count > 1 {
190            edge_count as f64 / (node_count * (node_count - 1)) as f64
191        } else {
192            0.0
193        };
194
195        let average_degree = if node_count > 0 {
196            (2 * edge_count) as f64 / node_count as f64
197        } else {
198            0.0
199        };
200
201        // Simplified clustering coefficient (would need more complex calculation)
202        let clustering_coefficient = self.estimate_clustering_coefficient(processor)?;
203
204        // Component analysis
205        let components = self.analyze_components(processor)?;
206        let largest_component_size = components.iter().map(|c| c.len()).max().unwrap_or(0);
207        let component_count = components.len();
208
209        Ok(GraphMetrics {
210            node_count,
211            edge_count,
212            density,
213            average_degree,
214            clustering_coefficient,
215            largest_component_size,
216            component_count,
217        })
218    }
219
220    /// Estimate clustering coefficient (simplified)
221    fn estimate_clustering_coefficient(&self, _processor: &IncrementalGraphProcessor) -> Result<f64> {
222        // Simplified implementation - in practice would calculate actual clustering
223        Ok(0.3) // Placeholder value
224    }
225
226    /// Analyze connected components
227    fn analyze_components(&self, processor: &IncrementalGraphProcessor) -> Result<Vec<Vec<String>>> {
228        let graph = processor.graph();
229        let edges_batch = &graph.edges;
230        let nodes_batch = &graph.nodes;
231
232        let mut components = Vec::new();
233        let mut visited = HashSet::new();
234        
235        // Get all nodes
236        let mut nodes = Vec::new();
237        if nodes_batch.num_rows() > 0 {
238            let node_ids = nodes_batch.column(0)
239                .as_any()
240                .downcast_ref::<arrow::array::StringArray>()
241                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for node IDs"))?;
242                
243            for i in 0..node_ids.len() {
244                nodes.push(node_ids.value(i).to_string());
245            }
246        }
247
248        // Build adjacency list
249        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
250        if edges_batch.num_rows() > 0 {
251            let source_ids = edges_batch.column(0)
252                .as_any()
253                .downcast_ref::<arrow::array::StringArray>()
254                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for source IDs"))?;
255            let target_ids = edges_batch.column(1)
256                .as_any()
257                .downcast_ref::<arrow::array::StringArray>()
258                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for target IDs"))?;
259                
260            for i in 0..source_ids.len() {
261                let source = source_ids.value(i).to_string();
262                let target = target_ids.value(i).to_string();
263                
264                adjacency.entry(source.clone()).or_insert_with(Vec::new).push(target.clone());
265                adjacency.entry(target).or_insert_with(Vec::new).push(source);
266            }
267        }
268
269        // Find components using DFS
270        for node in nodes {
271            if !visited.contains(&node) {
272                let mut component = Vec::new();
273                let mut stack = vec![node.clone()];
274                
275                while let Some(current) = stack.pop() {
276                    if visited.insert(current.clone()) {
277                        component.push(current.clone());
278                        
279                        if let Some(neighbors) = adjacency.get(&current) {
280                            for neighbor in neighbors {
281                                if !visited.contains(neighbor) {
282                                    stack.push(neighbor.clone());
283                                }
284                            }
285                        }
286                    }
287                }
288                
289                if !component.is_empty() {
290                    components.push(component);
291                }
292            }
293        }
294
295        Ok(components)
296    }
297
298    /// Detect communities using simplified algorithm
299    fn detect_communities(&self, processor: &IncrementalGraphProcessor) -> Result<HashMap<String, String>> {
300        // Simplified community detection - in practice would use more sophisticated algorithms
301        let components = self.analyze_components(processor)?;
302        let mut communities = HashMap::new();
303        
304        for (i, component) in components.iter().enumerate() {
305            let community_id = format!("community_{}", i);
306            for node in component {
307                communities.insert(node.clone(), community_id.clone());
308            }
309        }
310        
311        Ok(communities)
312    }
313
314    /// Detect anomalies based on metric changes
315    fn detect_metric_anomalies(&self, current: &GraphMetrics, timestamp: u64) -> Result<Vec<AnomalyEvent>> {
316        let mut anomalies = Vec::new();
317        let baseline = &self.baseline_metrics;
318
319        // Density anomalies
320        if baseline.density > 0.0 {
321            let density_change = (current.density - baseline.density).abs() / baseline.density;
322            if density_change > self.anomaly_thresholds.density_change {
323                let anomaly_type = if current.density > baseline.density {
324                    AnomalyType::DensitySpike
325                } else {
326                    AnomalyType::DensityDrop
327                };
328                
329                anomalies.push(AnomalyEvent {
330                    timestamp,
331                    anomaly_type,
332                    severity: (density_change / self.anomaly_thresholds.density_change).min(1.0),
333                    description: format!("Density changed by {:.2}%", density_change * 100.0),
334                    affected_nodes: Vec::new(),
335                });
336            }
337        }
338
339        // Degree anomalies
340        if baseline.average_degree > 0.0 {
341            let degree_change = (current.average_degree - baseline.average_degree).abs() / baseline.average_degree;
342            if degree_change > self.anomaly_thresholds.degree_change {
343                anomalies.push(AnomalyEvent {
344                    timestamp,
345                    anomaly_type: AnomalyType::DegreeBurst,
346                    severity: (degree_change / self.anomaly_thresholds.degree_change).min(1.0),
347                    description: format!("Average degree changed by {:.2}%", degree_change * 100.0),
348                    affected_nodes: Vec::new(),
349                });
350            }
351        }
352
353        // Component anomalies
354        if baseline.component_count > 0 {
355            let component_change = (current.component_count as f64 - baseline.component_count as f64).abs() / baseline.component_count as f64;
356            if component_change > self.anomaly_thresholds.component_change {
357                let anomaly_type = if current.component_count < baseline.component_count {
358                    AnomalyType::ComponentMerge
359                } else {
360                    AnomalyType::ComponentSplit
361                };
362                
363                anomalies.push(AnomalyEvent {
364                    timestamp,
365                    anomaly_type,
366                    severity: (component_change / self.anomaly_thresholds.component_change).min(1.0),
367                    description: format!("Component count changed from {} to {}", baseline.component_count, current.component_count),
368                    affected_nodes: Vec::new(),
369                });
370            }
371        }
372
373        Ok(anomalies)
374    }
375
376    /// Detect burst anomalies
377    fn detect_burst_anomalies(&self, changes: &UpdateResult, timestamp: u64) -> Result<Vec<AnomalyEvent>> {
378        let mut anomalies = Vec::new();
379
380        // Edge burst detection
381        if changes.edges_added as f64 > self.anomaly_thresholds.edge_burst_rate {
382            anomalies.push(AnomalyEvent {
383                timestamp,
384                anomaly_type: AnomalyType::EdgeBurst,
385                severity: (changes.edges_added as f64 / self.anomaly_thresholds.edge_burst_rate).min(1.0),
386                description: format!("Burst of {} edges added", changes.edges_added),
387                affected_nodes: Vec::new(),
388            });
389        }
390
391        // Node burst detection
392        if changes.vertices_added as f64 > self.anomaly_thresholds.node_burst_rate {
393            anomalies.push(AnomalyEvent {
394                timestamp,
395                anomaly_type: AnomalyType::NodeBurst,
396                severity: (changes.vertices_added as f64 / self.anomaly_thresholds.node_burst_rate).min(1.0),
397                description: format!("Burst of {} nodes added", changes.vertices_added),
398                affected_nodes: Vec::new(),
399            });
400        }
401
402        Ok(anomalies)
403    }
404
405    /// Detect community-based anomalies
406    fn detect_community_anomalies(&self, current_communities: &HashMap<String, String>, timestamp: u64) -> Result<Vec<AnomalyEvent>> {
407        let mut anomalies = Vec::new();
408
409        if let Some(previous_communities) = self.historical_communities.back() {
410            let stability = self.calculate_community_stability(previous_communities, current_communities);
411            
412            if stability < self.community_stability_threshold {
413                anomalies.push(AnomalyEvent {
414                    timestamp,
415                    anomaly_type: AnomalyType::CommunityShift,
416                    severity: 1.0 - stability,
417                    description: format!("Community stability: {:.2}%", stability * 100.0),
418                    affected_nodes: self.find_community_changes(previous_communities, current_communities),
419                });
420            }
421        }
422
423        Ok(anomalies)
424    }
425
426    /// Calculate community stability between two snapshots
427    fn calculate_community_stability(&self, previous: &HashMap<String, String>, current: &HashMap<String, String>) -> f64 {
428        let mut stable_assignments = 0;
429        let mut total_nodes = 0;
430
431        for (node, prev_community) in previous {
432            if let Some(curr_community) = current.get(node) {
433                if prev_community == curr_community {
434                    stable_assignments += 1;
435                }
436                total_nodes += 1;
437            }
438        }
439
440        if total_nodes > 0 {
441            stable_assignments as f64 / total_nodes as f64
442        } else {
443            1.0
444        }
445    }
446
447    /// Find nodes that changed communities
448    fn find_community_changes(&self, previous: &HashMap<String, String>, current: &HashMap<String, String>) -> Vec<String> {
449        let mut changed_nodes = Vec::new();
450
451        for (node, prev_community) in previous {
452            if let Some(curr_community) = current.get(node) {
453                if prev_community != curr_community {
454                    changed_nodes.push(node.clone());
455                }
456            }
457        }
458
459        changed_nodes
460    }
461
462    /// Update history with new data
463    fn update_history(&mut self, communities: HashMap<String, String>, anomalies: &[AnomalyEvent]) {
464        // Update community history
465        self.historical_communities.push_back(communities);
466        if self.historical_communities.len() > self.max_history_length {
467            self.historical_communities.pop_front();
468        }
469
470        // Update anomaly history
471        for anomaly in anomalies {
472            self.anomaly_history.push_back(anomaly.clone());
473        }
474        if self.anomaly_history.len() > self.max_history_length * 5 {
475            self.anomaly_history.pop_front();
476        }
477    }
478
479    /// Classify the type of change based on detected anomalies
480    fn classify_change(&self, anomalies: &[AnomalyEvent]) -> ChangeType {
481        if anomalies.iter().any(|a| a.anomaly_type == AnomalyType::CommunityShift) {
482            ChangeType::CommunityDrift
483        } else if anomalies.iter().any(|a| matches!(a.anomaly_type, AnomalyType::ComponentMerge | AnomalyType::ComponentSplit)) {
484            ChangeType::TopologyChange
485        } else if anomalies.iter().any(|a| matches!(a.anomaly_type, AnomalyType::EdgeBurst | AnomalyType::NodeBurst)) {
486            ChangeType::ScaleChange
487        } else {
488            ChangeType::TopologyChange
489        }
490    }
491
492    /// Calculate the magnitude of change
493    fn calculate_change_magnitude(&self, anomalies: &[AnomalyEvent]) -> f64 {
494        if anomalies.is_empty() {
495            0.0
496        } else {
497            anomalies.iter().map(|a| a.severity).sum::<f64>() / anomalies.len() as f64
498        }
499    }
500
501    /// Extract all affected nodes from anomalies
502    fn extract_affected_nodes(&self, anomalies: &[AnomalyEvent]) -> Vec<String> {
503        let mut nodes = HashSet::new();
504        for anomaly in anomalies {
505            for node in &anomaly.affected_nodes {
506                nodes.insert(node.clone());
507            }
508        }
509        nodes.into_iter().collect()
510    }
511
512    /// Get recent anomalies
513    pub fn recent_anomalies(&self, count: usize) -> Vec<&AnomalyEvent> {
514        self.anomaly_history.iter().rev().take(count).collect()
515    }
516
517    /// Get significant changes
518    pub fn significant_changes(&self) -> &[GraphChange] {
519        &self.significant_changes
520    }
521
522    /// Check if the graph is in an anomalous state
523    pub fn is_anomalous_state(&self) -> bool {
524        // Check if recent anomalies exceed threshold
525        let recent_severity: f64 = self.anomaly_history.iter()
526            .rev()
527            .take(3) // Last 3 anomalies
528            .map(|a| a.severity)
529            .sum();
530        
531        recent_severity > 2.0 // Threshold for anomalous state
532    }
533
534    /// Get current community stability
535    pub fn current_community_stability(&self) -> Option<f64> {
536        if self.historical_communities.len() >= 2 {
537            let current = self.historical_communities.back()?;
538            let previous = self.historical_communities.get(self.historical_communities.len() - 2)?;
539            Some(self.calculate_community_stability(previous, current))
540        } else {
541            None
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::graph::ArrowGraph;
550    use crate::streaming::incremental::IncrementalGraphProcessor;
551    use arrow::array::{StringArray, Float64Array};
552    use arrow::record_batch::RecordBatch;
553    use arrow::datatypes::{Schema, Field, DataType};
554    use std::sync::Arc;
555
556    fn create_test_graph() -> Result<ArrowGraph> {
557        let nodes_schema = Arc::new(Schema::new(vec![
558            Field::new("id", DataType::Utf8, false),
559        ]));
560        let node_ids = StringArray::from(vec!["A", "B", "C", "D"]);
561        let nodes_batch = RecordBatch::try_new(
562            nodes_schema,
563            vec![Arc::new(node_ids)],
564        )?;
565
566        let edges_schema = Arc::new(Schema::new(vec![
567            Field::new("source", DataType::Utf8, false),
568            Field::new("target", DataType::Utf8, false),
569            Field::new("weight", DataType::Float64, false),
570        ]));
571        let sources = StringArray::from(vec!["A", "B"]);
572        let targets = StringArray::from(vec!["B", "C"]);
573        let weights = Float64Array::from(vec![1.0, 1.0]);
574        let edges_batch = RecordBatch::try_new(
575            edges_schema,
576            vec![Arc::new(sources), Arc::new(targets), Arc::new(weights)],
577        )?;
578
579        ArrowGraph::new(nodes_batch, edges_batch)
580    }
581
582    #[test]
583    fn test_change_detector_initialization() {
584        let graph = create_test_graph().unwrap();
585        let processor = IncrementalGraphProcessor::new(graph).unwrap();
586        
587        let mut detector = GraphChangeDetector::default();
588        detector.initialize(&processor).unwrap();
589        
590        assert_eq!(detector.baseline_metrics.node_count, 4);
591        assert_eq!(detector.baseline_metrics.edge_count, 2);
592        assert!(detector.baseline_metrics.density > 0.0);
593        assert_eq!(detector.historical_communities.len(), 1);
594    }
595
596    #[test]
597    fn test_metric_computation() {
598        let graph = create_test_graph().unwrap();
599        let processor = IncrementalGraphProcessor::new(graph).unwrap();
600        
601        let detector = GraphChangeDetector::default();
602        let metrics = detector.compute_graph_metrics(&processor).unwrap();
603        
604        assert_eq!(metrics.node_count, 4);
605        assert_eq!(metrics.edge_count, 2);
606        assert!(metrics.density > 0.0);
607        assert!(metrics.average_degree > 0.0);
608    }
609
610    #[test]
611    fn test_component_analysis() {
612        let graph = create_test_graph().unwrap();
613        let processor = IncrementalGraphProcessor::new(graph).unwrap();
614        
615        let detector = GraphChangeDetector::default();
616        let components = detector.analyze_components(&processor).unwrap();
617        
618        assert_eq!(components.len(), 2); // {A,B,C} and {D}
619        
620        // Find the larger component
621        let larger_component = components.iter().max_by_key(|c| c.len()).unwrap();
622        assert_eq!(larger_component.len(), 3);
623    }
624
625    #[test]
626    fn test_burst_anomaly_detection() {
627        let graph = create_test_graph().unwrap();
628        let processor = IncrementalGraphProcessor::new(graph).unwrap();
629        
630        let detector = GraphChangeDetector::default();
631        
632        // Simulate edge burst
633        let edge_burst_changes = UpdateResult {
634            vertices_added: 0,
635            vertices_removed: 0,
636            edges_added: 15, // Above threshold of 10
637            edges_removed: 0,
638            affected_components: vec![],
639            recomputation_needed: false,
640        };
641        
642        let anomalies = detector.detect_burst_anomalies(&edge_burst_changes, 12345).unwrap();
643        assert_eq!(anomalies.len(), 1);
644        assert_eq!(anomalies[0].anomaly_type, AnomalyType::EdgeBurst);
645        assert!(anomalies[0].severity > 0.0);
646    }
647
648    #[test]
649    fn test_community_stability() {
650        let detector = GraphChangeDetector::default();
651        
652        let mut community1 = HashMap::new();
653        community1.insert("A".to_string(), "comm1".to_string());
654        community1.insert("B".to_string(), "comm1".to_string());
655        community1.insert("C".to_string(), "comm2".to_string());
656        
657        let mut community2 = HashMap::new();
658        community2.insert("A".to_string(), "comm1".to_string());
659        community2.insert("B".to_string(), "comm2".to_string()); // Changed
660        community2.insert("C".to_string(), "comm2".to_string());
661        
662        let stability = detector.calculate_community_stability(&community1, &community2);
663        assert!((stability - 0.666667).abs() < 0.001); // 2/3 stability
664    }
665
666    #[test]
667    fn test_full_anomaly_detection() {
668        let graph = create_test_graph().unwrap();
669        let mut processor = IncrementalGraphProcessor::new(graph).unwrap();
670        processor.set_batch_size(1);
671        
672        let mut detector = GraphChangeDetector::default();
673        detector.initialize(&processor).unwrap();
674        
675        // Add many edges to trigger anomaly
676        for i in 0..12 {
677            processor.add_edge(format!("node_{}", i), format!("node_{}", i + 1), 1.0).unwrap();
678        }
679        
680        let changes = UpdateResult {
681            vertices_added: 24, // Added nodes
682            vertices_removed: 0,
683            edges_added: 12,
684            edges_removed: 0,
685            affected_components: vec![],
686            recomputation_needed: false,
687        };
688        
689        let anomalies = detector.update(&processor, &changes).unwrap();
690        assert!(!anomalies.is_empty());
691        
692        // Should detect burst anomalies
693        assert!(anomalies.iter().any(|a| matches!(a.anomaly_type, AnomalyType::EdgeBurst | AnomalyType::NodeBurst)));
694    }
695
696    #[test]
697    fn test_anomalous_state_detection() {
698        let mut detector = GraphChangeDetector::default();
699        
700        // Add high-severity anomalies
701        for i in 0..3 {
702            detector.anomaly_history.push_back(AnomalyEvent {
703                timestamp: i,
704                anomaly_type: AnomalyType::DensitySpike,
705                severity: 0.8,
706                description: "Test anomaly".to_string(),
707                affected_nodes: vec![],
708            });
709        }
710        
711        assert!(detector.is_anomalous_state());
712        
713        // Clear history
714        detector.anomaly_history.clear();
715        assert!(!detector.is_anomalous_state());
716    }
717
718    #[test]
719    fn test_change_classification() {
720        let detector = GraphChangeDetector::default();
721        
722        let community_anomaly = AnomalyEvent {
723            timestamp: 0,
724            anomaly_type: AnomalyType::CommunityShift,
725            severity: 0.5,
726            description: "Test".to_string(),
727            affected_nodes: vec![],
728        };
729        
730        let change_type = detector.classify_change(&[community_anomaly]);
731        assert_eq!(change_type, ChangeType::CommunityDrift);
732        
733        let burst_anomaly = AnomalyEvent {
734            timestamp: 0,
735            anomaly_type: AnomalyType::EdgeBurst,
736            severity: 0.7,
737            description: "Test".to_string(),
738            affected_nodes: vec![],
739        };
740        
741        let change_type = detector.classify_change(&[burst_anomaly]);
742        assert_eq!(change_type, ChangeType::ScaleChange);
743    }
744}