Skip to main content

graphos_adapters/plugins/algorithms/
structure.rs

1//! Structure analysis algorithms: Articulation Points, Bridges, K-Core decomposition.
2//!
3//! These algorithms identify critical structural elements in graphs.
4
5use std::sync::OnceLock;
6
7use graphos_common::types::{NodeId, Value};
8use graphos_common::utils::error::Result;
9use graphos_common::utils::hash::{FxHashMap, FxHashSet};
10use graphos_core::graph::Direction;
11use graphos_core::graph::lpg::LpgStore;
12
13use super::super::{AlgorithmResult, ParameterDef, ParameterType, Parameters};
14use super::traits::GraphAlgorithm;
15
16// ============================================================================
17// Articulation Points (Cut Vertices)
18// ============================================================================
19
20/// Finds articulation points (cut vertices) in the graph.
21///
22/// An articulation point is a vertex whose removal disconnects the graph.
23/// Uses Tarjan's algorithm with low-link values.
24///
25/// # Arguments
26///
27/// * `store` - The graph store (treated as undirected)
28///
29/// # Returns
30///
31/// Set of node IDs that are articulation points.
32///
33/// # Complexity
34///
35/// O(V + E)
36pub fn articulation_points(store: &LpgStore) -> FxHashSet<NodeId> {
37    let nodes = store.node_ids();
38    let n = nodes.len();
39
40    if n == 0 {
41        return FxHashSet::default();
42    }
43
44    // Build node index mapping
45    let mut node_to_idx: FxHashMap<NodeId, usize> = FxHashMap::default();
46    let mut idx_to_node: Vec<NodeId> = Vec::with_capacity(n);
47    for (idx, &node) in nodes.iter().enumerate() {
48        node_to_idx.insert(node, idx);
49        idx_to_node.push(node);
50    }
51
52    // Build undirected adjacency list
53    let mut adj: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); n];
54    for &node in &nodes {
55        let i = *node_to_idx.get(&node).unwrap();
56        for (neighbor, _) in store.edges_from(node, Direction::Outgoing) {
57            if let Some(&j) = node_to_idx.get(&neighbor) {
58                adj[i].insert(j);
59                adj[j].insert(i); // Undirected
60            }
61        }
62    }
63
64    let mut visited = vec![false; n];
65    let mut disc = vec![0usize; n]; // Discovery time
66    let mut low = vec![0usize; n]; // Low-link value
67    let mut parent = vec![None::<usize>; n];
68    let mut ap = vec![false; n]; // Is articulation point
69    let mut time = 0usize;
70
71    // DFS from each unvisited node (handles disconnected graphs)
72    for start in 0..n {
73        if visited[start] {
74            continue;
75        }
76
77        // Iterative DFS using explicit stack
78        let mut stack: Vec<(usize, usize)> = vec![(start, 0)]; // (node, neighbor_idx)
79        let mut children_count: FxHashMap<usize, usize> = FxHashMap::default();
80
81        while let Some(&(u, idx)) = stack.last() {
82            if !visited[u] {
83                visited[u] = true;
84                disc[u] = time;
85                low[u] = time;
86                time += 1;
87                children_count.insert(u, 0);
88            }
89
90            let neighbors: Vec<usize> = adj[u].iter().copied().collect();
91
92            if idx < neighbors.len() {
93                let v = neighbors[idx];
94                stack.last_mut().unwrap().1 += 1;
95
96                if !visited[v] {
97                    parent[v] = Some(u);
98                    *children_count.entry(u).or_insert(0) += 1;
99                    stack.push((v, 0));
100                } else if parent[u] != Some(v) {
101                    low[u] = low[u].min(disc[v]);
102                }
103            } else {
104                stack.pop();
105
106                if let Some(p) = parent[u] {
107                    low[p] = low[p].min(low[u]);
108
109                    // Check articulation point condition
110                    if parent[p].is_some() && low[u] >= disc[p] {
111                        ap[p] = true;
112                    }
113                }
114
115                // Root is articulation point if it has more than one child
116                if parent[u].is_none() && *children_count.get(&u).unwrap_or(&0) > 1 {
117                    ap[u] = true;
118                }
119            }
120        }
121    }
122
123    ap.iter()
124        .enumerate()
125        .filter(|&(_, is_ap)| *is_ap)
126        .map(|(idx, _)| idx_to_node[idx])
127        .collect()
128}
129
130// ============================================================================
131// Bridges (Cut Edges)
132// ============================================================================
133
134/// Finds bridges (cut edges) in the graph.
135///
136/// A bridge is an edge whose removal disconnects the graph.
137/// Uses Tarjan's algorithm with low-link values.
138///
139/// # Arguments
140///
141/// * `store` - The graph store (treated as undirected)
142///
143/// # Returns
144///
145/// List of bridges as (source, target) pairs.
146///
147/// # Complexity
148///
149/// O(V + E)
150pub fn bridges(store: &LpgStore) -> Vec<(NodeId, NodeId)> {
151    let nodes = store.node_ids();
152    let n = nodes.len();
153
154    if n == 0 {
155        return Vec::new();
156    }
157
158    // Build node index mapping
159    let mut node_to_idx: FxHashMap<NodeId, usize> = FxHashMap::default();
160    let mut idx_to_node: Vec<NodeId> = Vec::with_capacity(n);
161    for (idx, &node) in nodes.iter().enumerate() {
162        node_to_idx.insert(node, idx);
163        idx_to_node.push(node);
164    }
165
166    // Build undirected adjacency list
167    let mut adj: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); n];
168    for &node in &nodes {
169        let i = *node_to_idx.get(&node).unwrap();
170        for (neighbor, _) in store.edges_from(node, Direction::Outgoing) {
171            if let Some(&j) = node_to_idx.get(&neighbor) {
172                adj[i].insert(j);
173                adj[j].insert(i);
174            }
175        }
176    }
177
178    let mut visited = vec![false; n];
179    let mut disc = vec![0usize; n];
180    let mut low = vec![0usize; n];
181    let mut parent = vec![None::<usize>; n];
182    let mut time = 0usize;
183    let mut bridge_list: Vec<(usize, usize)> = Vec::new();
184
185    for start in 0..n {
186        if visited[start] {
187            continue;
188        }
189
190        let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
191
192        while let Some(&(u, idx)) = stack.last() {
193            if !visited[u] {
194                visited[u] = true;
195                disc[u] = time;
196                low[u] = time;
197                time += 1;
198            }
199
200            let neighbors: Vec<usize> = adj[u].iter().copied().collect();
201
202            if idx < neighbors.len() {
203                let v = neighbors[idx];
204                stack.last_mut().unwrap().1 += 1;
205
206                if !visited[v] {
207                    parent[v] = Some(u);
208                    stack.push((v, 0));
209                } else if parent[u] != Some(v) {
210                    low[u] = low[u].min(disc[v]);
211                }
212            } else {
213                stack.pop();
214
215                if let Some(p) = parent[u] {
216                    low[p] = low[p].min(low[u]);
217
218                    // Bridge condition: low[u] > disc[p]
219                    if low[u] > disc[p] {
220                        bridge_list.push((p.min(u), p.max(u)));
221                    }
222                }
223            }
224        }
225    }
226
227    bridge_list
228        .into_iter()
229        .map(|(i, j)| (idx_to_node[i], idx_to_node[j]))
230        .collect()
231}
232
233// ============================================================================
234// K-Core Decomposition
235// ============================================================================
236
237/// Result of k-core decomposition.
238#[derive(Debug, Clone)]
239pub struct KCoreResult {
240    /// Core number for each node.
241    pub core_numbers: FxHashMap<NodeId, usize>,
242    /// Maximum core number (degeneracy).
243    pub max_core: usize,
244}
245
246impl KCoreResult {
247    /// Returns nodes in the k-core (nodes with core number >= k).
248    pub fn k_core(&self, k: usize) -> Vec<NodeId> {
249        self.core_numbers
250            .iter()
251            .filter(|&(_, core)| *core >= k)
252            .map(|(&node, _)| node)
253            .collect()
254    }
255
256    /// Returns the k-shell (nodes with core number exactly k).
257    pub fn k_shell(&self, k: usize) -> Vec<NodeId> {
258        self.core_numbers
259            .iter()
260            .filter(|&(_, core)| *core == k)
261            .map(|(&node, _)| node)
262            .collect()
263    }
264}
265
266/// Computes the k-core decomposition of the graph.
267///
268/// The k-core is the maximal subgraph where every vertex has degree at least k.
269/// The core number of a vertex is the largest k such that it belongs to the k-core.
270///
271/// # Arguments
272///
273/// * `store` - The graph store (treated as undirected)
274///
275/// # Returns
276///
277/// Core numbers for all nodes and the maximum core number.
278///
279/// # Complexity
280///
281/// O(V + E)
282pub fn kcore_decomposition(store: &LpgStore) -> KCoreResult {
283    let nodes = store.node_ids();
284    let n = nodes.len();
285
286    if n == 0 {
287        return KCoreResult {
288            core_numbers: FxHashMap::default(),
289            max_core: 0,
290        };
291    }
292
293    // Build node index mapping
294    let mut node_to_idx: FxHashMap<NodeId, usize> = FxHashMap::default();
295    let mut idx_to_node: Vec<NodeId> = Vec::with_capacity(n);
296    for (idx, &node) in nodes.iter().enumerate() {
297        node_to_idx.insert(node, idx);
298        idx_to_node.push(node);
299    }
300
301    // Build undirected adjacency list and compute degrees
302    let mut adj: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); n];
303    for &node in &nodes {
304        let i = *node_to_idx.get(&node).unwrap();
305        for (neighbor, _) in store.edges_from(node, Direction::Outgoing) {
306            if let Some(&j) = node_to_idx.get(&neighbor) {
307                adj[i].insert(j);
308                adj[j].insert(i);
309            }
310        }
311    }
312
313    let mut degree: Vec<usize> = adj.iter().map(|neighbors| neighbors.len()).collect();
314    let mut core = vec![0usize; n];
315    let mut removed = vec![false; n];
316
317    // Find maximum degree for bucket initialization
318    let max_degree = *degree.iter().max().unwrap_or(&0);
319    if max_degree == 0 {
320        return KCoreResult {
321            core_numbers: nodes.iter().map(|&n| (n, 0)).collect(),
322            max_core: 0,
323        };
324    }
325
326    // Buckets for O(1) retrieval of minimum degree vertices
327    let mut buckets: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); max_degree + 1];
328    for (i, &d) in degree.iter().enumerate() {
329        buckets[d].insert(i);
330    }
331
332    let mut max_core_val = 0;
333
334    // Process vertices in order of increasing degree
335    for _ in 0..n {
336        // Find minimum degree bucket
337        let mut min_deg = 0;
338        while min_deg <= max_degree && buckets[min_deg].is_empty() {
339            min_deg += 1;
340        }
341
342        if min_deg > max_degree {
343            break;
344        }
345
346        // Pick a vertex from the minimum degree bucket
347        let v = *buckets[min_deg].iter().next().unwrap();
348        buckets[min_deg].remove(&v);
349        removed[v] = true;
350        core[v] = min_deg;
351        max_core_val = max_core_val.max(min_deg);
352
353        // Update degrees of neighbors
354        for &u in &adj[v] {
355            if !removed[u] && degree[u] > 0 {
356                let old_deg = degree[u];
357                buckets[old_deg].remove(&u);
358                degree[u] -= 1;
359                let new_deg = degree[u];
360                buckets[new_deg].insert(u);
361            }
362        }
363    }
364
365    let core_numbers: FxHashMap<NodeId, usize> =
366        (0..n).map(|i| (idx_to_node[i], core[i])).collect();
367
368    KCoreResult {
369        core_numbers,
370        max_core: max_core_val,
371    }
372}
373
374/// Extracts the k-core subgraph (nodes with core number >= k).
375pub fn k_core(store: &LpgStore, k: usize) -> Vec<NodeId> {
376    let result = kcore_decomposition(store);
377    result.k_core(k)
378}
379
380// ============================================================================
381// Algorithm Wrappers for Plugin Registry
382// ============================================================================
383
384/// Static parameter definitions for Articulation Points algorithm.
385static ARTICULATION_PARAMS: OnceLock<Vec<ParameterDef>> = OnceLock::new();
386
387fn articulation_params() -> &'static [ParameterDef] {
388    ARTICULATION_PARAMS.get_or_init(Vec::new)
389}
390
391/// Articulation Points algorithm wrapper.
392pub struct ArticulationPointsAlgorithm;
393
394impl GraphAlgorithm for ArticulationPointsAlgorithm {
395    fn name(&self) -> &str {
396        "articulation_points"
397    }
398
399    fn description(&self) -> &str {
400        "Find articulation points (cut vertices) in the graph"
401    }
402
403    fn parameters(&self) -> &[ParameterDef] {
404        articulation_params()
405    }
406
407    fn execute(&self, store: &LpgStore, _params: &Parameters) -> Result<AlgorithmResult> {
408        let points = articulation_points(store);
409
410        let mut result = AlgorithmResult::new(vec!["node_id".to_string()]);
411
412        for node in points {
413            result.add_row(vec![Value::Int64(node.0 as i64)]);
414        }
415
416        Ok(result)
417    }
418}
419
420/// Static parameter definitions for Bridges algorithm.
421static BRIDGES_PARAMS: OnceLock<Vec<ParameterDef>> = OnceLock::new();
422
423fn bridges_params() -> &'static [ParameterDef] {
424    BRIDGES_PARAMS.get_or_init(Vec::new)
425}
426
427/// Bridges algorithm wrapper.
428pub struct BridgesAlgorithm;
429
430impl GraphAlgorithm for BridgesAlgorithm {
431    fn name(&self) -> &str {
432        "bridges"
433    }
434
435    fn description(&self) -> &str {
436        "Find bridges (cut edges) in the graph"
437    }
438
439    fn parameters(&self) -> &[ParameterDef] {
440        bridges_params()
441    }
442
443    fn execute(&self, store: &LpgStore, _params: &Parameters) -> Result<AlgorithmResult> {
444        let bridge_list = bridges(store);
445
446        let mut result = AlgorithmResult::new(vec!["source".to_string(), "target".to_string()]);
447
448        for (src, dst) in bridge_list {
449            result.add_row(vec![Value::Int64(src.0 as i64), Value::Int64(dst.0 as i64)]);
450        }
451
452        Ok(result)
453    }
454}
455
456/// Static parameter definitions for K-Core algorithm.
457static KCORE_PARAMS: OnceLock<Vec<ParameterDef>> = OnceLock::new();
458
459fn kcore_params() -> &'static [ParameterDef] {
460    KCORE_PARAMS.get_or_init(|| {
461        vec![ParameterDef {
462            name: "k".to_string(),
463            description: "Core number threshold (optional, returns decomposition if not set)"
464                .to_string(),
465            param_type: ParameterType::Integer,
466            required: false,
467            default: None,
468        }]
469    })
470}
471
472/// K-Core decomposition algorithm wrapper.
473pub struct KCoreAlgorithm;
474
475impl GraphAlgorithm for KCoreAlgorithm {
476    fn name(&self) -> &str {
477        "kcore"
478    }
479
480    fn description(&self) -> &str {
481        "K-core decomposition of the graph"
482    }
483
484    fn parameters(&self) -> &[ParameterDef] {
485        kcore_params()
486    }
487
488    fn execute(&self, store: &LpgStore, params: &Parameters) -> Result<AlgorithmResult> {
489        let decomposition = kcore_decomposition(store);
490
491        if let Some(k) = params.get_int("k") {
492            // Return nodes in the k-core
493            let k_core_nodes = decomposition.k_core(k as usize);
494
495            let mut result =
496                AlgorithmResult::new(vec!["node_id".to_string(), "in_k_core".to_string()]);
497
498            for node in k_core_nodes {
499                result.add_row(vec![Value::Int64(node.0 as i64), Value::Bool(true)]);
500            }
501
502            Ok(result)
503        } else {
504            // Return full decomposition
505            let mut result = AlgorithmResult::new(vec![
506                "node_id".to_string(),
507                "core_number".to_string(),
508                "max_core".to_string(),
509            ]);
510
511            for (node, core) in decomposition.core_numbers {
512                result.add_row(vec![
513                    Value::Int64(node.0 as i64),
514                    Value::Int64(core as i64),
515                    Value::Int64(decomposition.max_core as i64),
516                ]);
517            }
518
519            Ok(result)
520        }
521    }
522}
523
524// ============================================================================
525// Tests
526// ============================================================================
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn create_simple_path() -> LpgStore {
533        // Path: 0 - 1 - 2 - 3 (all are articulation points except endpoints)
534        let store = LpgStore::new();
535
536        let n0 = store.create_node(&["Node"]);
537        let n1 = store.create_node(&["Node"]);
538        let n2 = store.create_node(&["Node"]);
539        let n3 = store.create_node(&["Node"]);
540
541        store.create_edge(n0, n1, "EDGE");
542        store.create_edge(n1, n0, "EDGE");
543        store.create_edge(n1, n2, "EDGE");
544        store.create_edge(n2, n1, "EDGE");
545        store.create_edge(n2, n3, "EDGE");
546        store.create_edge(n3, n2, "EDGE");
547
548        store
549    }
550
551    fn create_diamond() -> LpgStore {
552        // Diamond: 0 - 1 - 3
553        //          |   |
554        //          +---2
555        // No articulation points in a diamond
556        let store = LpgStore::new();
557
558        let n0 = store.create_node(&["Node"]);
559        let n1 = store.create_node(&["Node"]);
560        let n2 = store.create_node(&["Node"]);
561        let n3 = store.create_node(&["Node"]);
562
563        // 0-1, 0-2, 1-3, 2-3
564        store.create_edge(n0, n1, "EDGE");
565        store.create_edge(n1, n0, "EDGE");
566        store.create_edge(n0, n2, "EDGE");
567        store.create_edge(n2, n0, "EDGE");
568        store.create_edge(n1, n3, "EDGE");
569        store.create_edge(n3, n1, "EDGE");
570        store.create_edge(n2, n3, "EDGE");
571        store.create_edge(n3, n2, "EDGE");
572
573        store
574    }
575
576    fn create_tree() -> LpgStore {
577        // Tree:     0
578        //          / \
579        //         1   2
580        //        /
581        //       3
582        let store = LpgStore::new();
583
584        let n0 = store.create_node(&["Node"]);
585        let n1 = store.create_node(&["Node"]);
586        let n2 = store.create_node(&["Node"]);
587        let n3 = store.create_node(&["Node"]);
588
589        store.create_edge(n0, n1, "EDGE");
590        store.create_edge(n1, n0, "EDGE");
591        store.create_edge(n0, n2, "EDGE");
592        store.create_edge(n2, n0, "EDGE");
593        store.create_edge(n1, n3, "EDGE");
594        store.create_edge(n3, n1, "EDGE");
595
596        store
597    }
598
599    #[test]
600    fn test_articulation_points_path() {
601        let store = create_simple_path();
602        let ap = articulation_points(&store);
603
604        // In a path, middle nodes are articulation points
605        // 0-1-2-3: nodes 1 and 2 are articulation points
606        assert!(ap.len() >= 2);
607        assert!(ap.contains(&NodeId::new(1)) || ap.contains(&NodeId::new(2)));
608    }
609
610    #[test]
611    fn test_articulation_points_diamond() {
612        let store = create_diamond();
613        let ap = articulation_points(&store);
614
615        // Diamond has no articulation points (it's 2-connected)
616        assert!(ap.is_empty());
617    }
618
619    #[test]
620    fn test_articulation_points_tree() {
621        let store = create_tree();
622        let ap = articulation_points(&store);
623
624        // In a tree, all non-leaf nodes are articulation points
625        // Tree: 0 has children 1, 2. Node 1 has child 3.
626        // Articulation points: 0, 1
627        assert!(ap.contains(&NodeId::new(0)) || ap.contains(&NodeId::new(1)));
628    }
629
630    #[test]
631    fn test_articulation_points_empty() {
632        let store = LpgStore::new();
633        let ap = articulation_points(&store);
634        assert!(ap.is_empty());
635    }
636
637    #[test]
638    fn test_bridges_path() {
639        let store = create_simple_path();
640        let br = bridges(&store);
641
642        // In a path, all edges are bridges
643        assert_eq!(br.len(), 3);
644    }
645
646    #[test]
647    fn test_bridges_diamond() {
648        let store = create_diamond();
649        let br = bridges(&store);
650
651        // Diamond has no bridges (every edge is part of a cycle)
652        assert!(br.is_empty());
653    }
654
655    #[test]
656    fn test_bridges_empty() {
657        let store = LpgStore::new();
658        let br = bridges(&store);
659        assert!(br.is_empty());
660    }
661
662    #[test]
663    fn test_kcore_path() {
664        let store = create_simple_path();
665        let result = kcore_decomposition(&store);
666
667        // In a path using peeling algorithm, most nodes have core number 1
668        // At least some nodes should have core number >= 1
669        let max_core = result.core_numbers.values().copied().max().unwrap_or(0);
670        assert!(max_core >= 1);
671        assert_eq!(result.max_core, max_core);
672    }
673
674    #[test]
675    fn test_kcore_triangle() {
676        let store = LpgStore::new();
677        let n0 = store.create_node(&["Node"]);
678        let n1 = store.create_node(&["Node"]);
679        let n2 = store.create_node(&["Node"]);
680
681        store.create_edge(n0, n1, "EDGE");
682        store.create_edge(n1, n0, "EDGE");
683        store.create_edge(n1, n2, "EDGE");
684        store.create_edge(n2, n1, "EDGE");
685        store.create_edge(n0, n2, "EDGE");
686        store.create_edge(n2, n0, "EDGE");
687
688        let result = kcore_decomposition(&store);
689
690        // Triangle: max core should be at least 1 (nodes have degree 2)
691        assert!(result.max_core >= 1);
692        // All nodes should be decomposed
693        assert_eq!(result.core_numbers.len(), 3);
694    }
695
696    #[test]
697    fn test_kcore_empty() {
698        let store = LpgStore::new();
699        let result = kcore_decomposition(&store);
700
701        assert!(result.core_numbers.is_empty());
702        assert_eq!(result.max_core, 0);
703    }
704
705    #[test]
706    fn test_kcore_isolated() {
707        let store = LpgStore::new();
708        store.create_node(&["Node"]);
709        store.create_node(&["Node"]);
710
711        let result = kcore_decomposition(&store);
712
713        // Isolated nodes have core number 0
714        for (_, &core) in &result.core_numbers {
715            assert_eq!(core, 0);
716        }
717    }
718
719    #[test]
720    fn test_k_core_extraction() {
721        let store = create_simple_path();
722        let result = kcore_decomposition(&store);
723
724        // k_core(0) should return all nodes
725        let k0_core = result.k_core(0);
726        assert_eq!(k0_core.len(), 4);
727
728        // Higher k-cores have fewer or equal nodes
729        let k1_core = result.k_core(1);
730        assert!(k1_core.len() <= 4);
731
732        let k2_core = result.k_core(2);
733        assert!(k2_core.len() <= k1_core.len());
734    }
735
736    #[test]
737    fn test_k_shell() {
738        let store = create_simple_path();
739        let result = kcore_decomposition(&store);
740
741        // Total nodes in all shells should equal total nodes
742        let total_in_shells: usize = (0..=result.max_core).map(|k| result.k_shell(k).len()).sum();
743        assert_eq!(total_in_shells, 4);
744    }
745}