Skip to main content

rust_igraph/algorithms/properties/
bipartivity_ratios.rs

1//! Bipartivity ratio indices (ALGO-TR-109).
2//!
3//! Measures capturing how close a graph is to being bipartite:
4//!
5//! - **Bipartivity index** — fraction of vertices in a max-cut
6//!   two-coloring (BFS greedy)
7//! - **Frustration ratio** — fraction of edges that violate a
8//!   two-coloring (bipartite edges / total edges)
9//! - **Odd cycle density** — fraction of triangles (odd cycles of
10//!   length 3) relative to max possible
11//! - **Even-odd walk ratio** — ratio of even-length to odd-length
12//!   walks of length 2 (via degree sums)
13
14#![allow(
15    clippy::cast_possible_truncation,
16    clippy::cast_precision_loss,
17    clippy::many_single_char_names,
18    clippy::needless_range_loop,
19    clippy::similar_names,
20    clippy::too_many_lines
21)]
22
23use crate::core::{Graph, IgraphResult};
24
25/// Compute the bipartivity index.
26///
27/// Uses a BFS greedy 2-coloring to partition vertices into two sets,
28/// then returns `max(|A|, |B|) / n` — the fraction of vertices in
29/// the larger partition. For a perfectly bipartite graph this equals
30/// the larger side fraction; for complete graphs it approaches 0.5.
31/// Returns 0.0 for empty graphs.
32///
33/// # Examples
34///
35/// ```
36/// use rust_igraph::{Graph, bipartivity_index};
37///
38/// // Path 0-1-2 is bipartite with partition {0,2} vs {1} → 2/3
39/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
40/// assert!((bipartivity_index(&g).unwrap() - 2.0/3.0).abs() < 1e-10);
41/// ```
42pub fn bipartivity_index(graph: &Graph) -> IgraphResult<f64> {
43    let n = graph.vcount() as usize;
44    if n == 0 {
45        return Ok(0.0);
46    }
47
48    let mut color: Vec<i8> = vec![-1; n];
49    let mut side_a = 0_u64;
50    let mut side_b = 0_u64;
51
52    for start in 0..n {
53        if color[start] != -1 {
54            continue;
55        }
56        color[start] = 0;
57        side_a += 1;
58        let mut queue = std::collections::VecDeque::new();
59        queue.push_back(start);
60        while let Some(v) = queue.pop_front() {
61            let nbrs = graph.neighbors(v as u32)?;
62            let next_color = 1 - color[v];
63            for &u in &nbrs {
64                let ui = u as usize;
65                if color[ui] == -1 {
66                    color[ui] = next_color;
67                    if next_color == 0 {
68                        side_a += 1;
69                    } else {
70                        side_b += 1;
71                    }
72                    queue.push_back(ui);
73                }
74            }
75        }
76    }
77
78    let larger = side_a.max(side_b);
79    Ok(larger as f64 / n as f64)
80}
81
82/// Compute the frustration ratio.
83///
84/// Fraction of edges that are "frustrated" (connect same-color vertices)
85/// under a BFS greedy 2-coloring. For bipartite graphs this is 0.0;
86/// for complete graphs on n≥3 vertices it is positive. Returns 0.0
87/// for graphs with no edges.
88///
89/// # Examples
90///
91/// ```
92/// use rust_igraph::{Graph, frustration_ratio};
93///
94/// // K_3: BFS colors 0→A, 1→B, 2→B; edge (1,2) frustrated → 1/3
95/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
96/// assert!((frustration_ratio(&g).unwrap() - 1.0/3.0).abs() < 1e-10);
97/// ```
98pub fn frustration_ratio(graph: &Graph) -> IgraphResult<f64> {
99    let n = graph.vcount() as usize;
100    let m = graph.ecount();
101    if m == 0 {
102        return Ok(0.0);
103    }
104
105    let mut color: Vec<i8> = vec![-1; n];
106
107    for start in 0..n {
108        if color[start] != -1 {
109            continue;
110        }
111        color[start] = 0;
112        let mut queue = std::collections::VecDeque::new();
113        queue.push_back(start);
114        while let Some(v) = queue.pop_front() {
115            let nbrs = graph.neighbors(v as u32)?;
116            let next_color = 1 - color[v];
117            for &u in &nbrs {
118                let ui = u as usize;
119                if color[ui] == -1 {
120                    color[ui] = next_color;
121                    queue.push_back(ui);
122                }
123            }
124        }
125    }
126
127    let mut frustrated = 0_u64;
128    for v in 0..n {
129        let nbrs = graph.neighbors(v as u32)?;
130        for &u in &nbrs {
131            let ui = u as usize;
132            if ui > v && color[v] == color[ui] {
133                frustrated += 1;
134            }
135        }
136    }
137
138    Ok(frustrated as f64 / m as f64)
139}
140
141/// Compute the odd cycle density.
142///
143/// Fraction of closed triplets (triangles) relative to possible triplets.
144/// This equals the global clustering coefficient, but interpreted here as
145/// a measure of odd-cycle (length-3) density — a non-zero value proves
146/// the graph is not bipartite. Returns 0.0 for triangle-free or trivial
147/// graphs.
148///
149/// # Examples
150///
151/// ```
152/// use rust_igraph::{Graph, odd_cycle_density};
153///
154/// // K_4: C=1.0 (all triplets closed)
155/// let g = Graph::from_edges(
156///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
157/// ).unwrap();
158/// assert!((odd_cycle_density(&g).unwrap() - 1.0).abs() < 1e-10);
159/// ```
160pub fn odd_cycle_density(graph: &Graph) -> IgraphResult<f64> {
161    let n = graph.vcount() as usize;
162    if n < 3 {
163        return Ok(0.0);
164    }
165
166    let mut total_triplets = 0_u64;
167    let mut closed_triplets = 0_u64;
168
169    for v in 0..n {
170        let neighbors = graph.neighbors(v as u32)?;
171        let d = neighbors.len();
172        if d < 2 {
173            continue;
174        }
175        let possible = (d * (d - 1)) / 2;
176        total_triplets += possible as u64;
177
178        for i in 0..d {
179            for j in (i + 1)..d {
180                if graph.has_edge(neighbors[i], neighbors[j]) {
181                    closed_triplets += 1;
182                }
183            }
184        }
185    }
186
187    if total_triplets == 0 {
188        return Ok(0.0);
189    }
190
191    Ok(closed_triplets as f64 / total_triplets as f64)
192}
193
194/// Compute the even-odd walk ratio.
195///
196/// Ratio of even-length walks of length 2 to odd-length walks of
197/// length 1 (edges). A walk of length 2 from u to w passes through
198/// some intermediate v — the total count is `Σ_v d(v)²` (counting
199/// ordered pairs of neighbors). The odd-length count is `2m` (each
200/// edge contributes 2 directed walks of length 1).
201///
202/// Returns `(Σ d²) / (2m)` — for bipartite graphs this relates to
203/// the ratio of even vs odd spectral moments. Returns 0.0 for graphs
204/// with no edges.
205///
206/// # Examples
207///
208/// ```
209/// use rust_igraph::{Graph, even_odd_walk_ratio};
210///
211/// // Path 0-1-2: degrees [1,2,1], Σd²=1+4+1=6, 2m=4 → 6/4=1.5
212/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
213/// assert!((even_odd_walk_ratio(&g).unwrap() - 1.5).abs() < 1e-10);
214/// ```
215pub fn even_odd_walk_ratio(graph: &Graph) -> IgraphResult<f64> {
216    let n = graph.vcount() as usize;
217    let m = graph.ecount();
218    if m == 0 {
219        return Ok(0.0);
220    }
221
222    let mut sum_deg_sq = 0_u64;
223    for v in 0..n {
224        let d = graph.degree(v as u32)? as u64;
225        sum_deg_sq += d * d;
226    }
227
228    let two_m = 2 * m as u64;
229    Ok(sum_deg_sq as f64 / two_m as f64)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn empty() -> Graph {
237        Graph::with_vertices(0)
238    }
239
240    fn single() -> Graph {
241        Graph::with_vertices(1)
242    }
243
244    fn single_edge() -> Graph {
245        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
246    }
247
248    fn path3() -> Graph {
249        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
250    }
251
252    fn k3() -> Graph {
253        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
254    }
255
256    fn k4() -> Graph {
257        Graph::from_edges(
258            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
259            false,
260            Some(4),
261        )
262        .unwrap()
263    }
264
265    fn cycle4() -> Graph {
266        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
267    }
268
269    fn cycle5() -> Graph {
270        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)], false, Some(5)).unwrap()
271    }
272
273    fn star5() -> Graph {
274        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
275    }
276
277    fn paw() -> Graph {
278        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
279    }
280
281    fn bipartite_k23() -> Graph {
282        // K_{2,3}: vertices {0,1} connected to {2,3,4}
283        Graph::from_edges(
284            &[(0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (1, 4)],
285            false,
286            Some(5),
287        )
288        .unwrap()
289    }
290
291    // --- bipartivity_index ---
292
293    #[test]
294    fn bi_empty() {
295        assert!(bipartivity_index(&empty()).unwrap().abs() < 1e-10);
296    }
297
298    #[test]
299    fn bi_single() {
300        assert!((bipartivity_index(&single()).unwrap() - 1.0).abs() < 1e-10);
301    }
302
303    #[test]
304    fn bi_single_edge() {
305        // {0} vs {1} → max(1,1)/2 = 0.5
306        assert!((bipartivity_index(&single_edge()).unwrap() - 0.5).abs() < 1e-10);
307    }
308
309    #[test]
310    fn bi_path3() {
311        // Bipartite: {0,2} vs {1} → max(2,1)/3 = 2/3
312        assert!((bipartivity_index(&path3()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
313    }
314
315    #[test]
316    fn bi_cycle4() {
317        // Bipartite: {0,2} vs {1,3} → max(2,2)/4 = 0.5
318        assert!((bipartivity_index(&cycle4()).unwrap() - 0.5).abs() < 1e-10);
319    }
320
321    #[test]
322    fn bi_bipartite_k23() {
323        // {0,1} vs {2,3,4} → max(3,2)/5 = 3/5
324        assert!((bipartivity_index(&bipartite_k23()).unwrap() - 3.0 / 5.0).abs() < 1e-10);
325    }
326
327    #[test]
328    fn bi_in_range() {
329        for g in &[
330            empty(),
331            single(),
332            single_edge(),
333            path3(),
334            k3(),
335            k4(),
336            cycle4(),
337            star5(),
338            paw(),
339        ] {
340            let r = bipartivity_index(g).unwrap();
341            assert!(r >= -1e-10);
342            assert!(r <= 1.0 + 1e-10);
343        }
344    }
345
346    // --- frustration_ratio ---
347
348    #[test]
349    fn fr_empty() {
350        assert!(frustration_ratio(&empty()).unwrap().abs() < 1e-10);
351    }
352
353    #[test]
354    fn fr_single_edge() {
355        // Bipartite → 0 frustrated
356        assert!(frustration_ratio(&single_edge()).unwrap().abs() < 1e-10);
357    }
358
359    #[test]
360    fn fr_path3() {
361        // Bipartite → 0 frustrated
362        assert!(frustration_ratio(&path3()).unwrap().abs() < 1e-10);
363    }
364
365    #[test]
366    fn fr_cycle4() {
367        // Bipartite → 0 frustrated
368        assert!(frustration_ratio(&cycle4()).unwrap().abs() < 1e-10);
369    }
370
371    #[test]
372    fn fr_bipartite_k23() {
373        assert!(frustration_ratio(&bipartite_k23()).unwrap().abs() < 1e-10);
374    }
375
376    #[test]
377    fn fr_k3() {
378        // BFS: 0→A, 1→B, 2→B; edge(1,2) frustrated → 1/3
379        assert!((frustration_ratio(&k3()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
380    }
381
382    #[test]
383    fn fr_cycle5() {
384        // Odd cycle: BFS 0→A,1→B,2→A,3→B,4→A; edge(4,0) same color → 1/5
385        assert!((frustration_ratio(&cycle5()).unwrap() - 1.0 / 5.0).abs() < 1e-10);
386    }
387
388    #[test]
389    fn fr_in_01() {
390        for g in &[
391            single_edge(),
392            path3(),
393            k3(),
394            k4(),
395            cycle4(),
396            cycle5(),
397            star5(),
398            paw(),
399        ] {
400            let r = frustration_ratio(g).unwrap();
401            assert!(r >= -1e-10);
402            assert!(r <= 1.0 + 1e-10);
403        }
404    }
405
406    // --- odd_cycle_density ---
407
408    #[test]
409    fn ocd_empty() {
410        assert!(odd_cycle_density(&empty()).unwrap().abs() < 1e-10);
411    }
412
413    #[test]
414    fn ocd_single() {
415        assert!(odd_cycle_density(&single()).unwrap().abs() < 1e-10);
416    }
417
418    #[test]
419    fn ocd_path3() {
420        // No triangles
421        assert!(odd_cycle_density(&path3()).unwrap().abs() < 1e-10);
422    }
423
424    #[test]
425    fn ocd_cycle4() {
426        // No triangles
427        assert!(odd_cycle_density(&cycle4()).unwrap().abs() < 1e-10);
428    }
429
430    #[test]
431    fn ocd_bipartite_k23() {
432        // Bipartite → no odd cycles of length 3
433        assert!(odd_cycle_density(&bipartite_k23()).unwrap().abs() < 1e-10);
434    }
435
436    #[test]
437    fn ocd_k3() {
438        // C = 1.0
439        assert!((odd_cycle_density(&k3()).unwrap() - 1.0).abs() < 1e-10);
440    }
441
442    #[test]
443    fn ocd_k4() {
444        // C = 1.0
445        assert!((odd_cycle_density(&k4()).unwrap() - 1.0).abs() < 1e-10);
446    }
447
448    #[test]
449    fn ocd_star5() {
450        // No triangles
451        assert!(odd_cycle_density(&star5()).unwrap().abs() < 1e-10);
452    }
453
454    #[test]
455    fn ocd_paw() {
456        // Paw: degrees [2,2,3,1]. Triplets at v=0: C(2,2)=1, v=1: C(2,2)=1, v=2: C(3,2)=3
457        // Total = 5. Closed: (0,1,2) triangle → each endpoint contributes 1 closed → 3 closed
458        // But wait — closed_triplets counts per-vertex contribution:
459        // v=0: nbrs={1,2}, edge(1,2)? yes → 1
460        // v=1: nbrs={0,2}, edge(0,2)? yes → 1
461        // v=2: nbrs={0,1,3}, pairs: (0,1)→yes, (0,3)→no, (1,3)→no → 1
462        // Total closed = 3, total triplets = 1+1+3 = 5
463        // Clustering = 3/5 = 0.6
464        let r = odd_cycle_density(&paw()).unwrap();
465        assert!((r - 3.0 / 5.0).abs() < 1e-10);
466    }
467
468    #[test]
469    fn ocd_in_01() {
470        for g in &[path3(), k3(), k4(), cycle4(), star5(), paw()] {
471            let r = odd_cycle_density(g).unwrap();
472            assert!(r >= -1e-10);
473            assert!(r <= 1.0 + 1e-10);
474        }
475    }
476
477    // --- even_odd_walk_ratio ---
478
479    #[test]
480    fn eowr_empty() {
481        assert!(even_odd_walk_ratio(&empty()).unwrap().abs() < 1e-10);
482    }
483
484    #[test]
485    fn eowr_single() {
486        assert!(even_odd_walk_ratio(&single()).unwrap().abs() < 1e-10);
487    }
488
489    #[test]
490    fn eowr_single_edge() {
491        // degrees [1,1], Σd²=2, 2m=2 → 1.0
492        assert!((even_odd_walk_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
493    }
494
495    #[test]
496    fn eowr_path3() {
497        // degrees [1,2,1], Σd²=6, 2m=4 → 1.5
498        assert!((even_odd_walk_ratio(&path3()).unwrap() - 1.5).abs() < 1e-10);
499    }
500
501    #[test]
502    fn eowr_k3() {
503        // degrees [2,2,2], Σd²=12, 2m=6 → 2.0
504        assert!((even_odd_walk_ratio(&k3()).unwrap() - 2.0).abs() < 1e-10);
505    }
506
507    #[test]
508    fn eowr_k4() {
509        // degrees [3,3,3,3], Σd²=36, 2m=12 → 3.0
510        assert!((even_odd_walk_ratio(&k4()).unwrap() - 3.0).abs() < 1e-10);
511    }
512
513    #[test]
514    fn eowr_cycle4() {
515        // degrees [2,2,2,2], Σd²=16, 2m=8 → 2.0
516        assert!((even_odd_walk_ratio(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
517    }
518
519    #[test]
520    fn eowr_star5() {
521        // degrees [4,1,1,1,1], Σd²=16+4=20, 2m=8 → 2.5
522        assert!((even_odd_walk_ratio(&star5()).unwrap() - 2.5).abs() < 1e-10);
523    }
524
525    #[test]
526    fn eowr_positive() {
527        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
528            assert!(even_odd_walk_ratio(g).unwrap() > 0.0);
529        }
530    }
531
532    // --- cross-consistency ---
533
534    #[test]
535    fn bipartite_zero_frustration() {
536        assert!(frustration_ratio(&path3()).unwrap().abs() < 1e-10);
537        assert!(frustration_ratio(&cycle4()).unwrap().abs() < 1e-10);
538        assert!(frustration_ratio(&star5()).unwrap().abs() < 1e-10);
539        assert!(frustration_ratio(&bipartite_k23()).unwrap().abs() < 1e-10);
540    }
541
542    #[test]
543    fn bipartite_zero_odd_cycles() {
544        assert!(odd_cycle_density(&path3()).unwrap().abs() < 1e-10);
545        assert!(odd_cycle_density(&cycle4()).unwrap().abs() < 1e-10);
546        assert!(odd_cycle_density(&star5()).unwrap().abs() < 1e-10);
547        assert!(odd_cycle_density(&bipartite_k23()).unwrap().abs() < 1e-10);
548    }
549
550    #[test]
551    fn regular_graph_walk_ratio_equals_degree() {
552        // For k-regular graph: Σd²=n·k², 2m=n·k → ratio = k
553        assert!((even_odd_walk_ratio(&k3()).unwrap() - 2.0).abs() < 1e-10);
554        assert!((even_odd_walk_ratio(&k4()).unwrap() - 3.0).abs() < 1e-10);
555        assert!((even_odd_walk_ratio(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
556    }
557}