Skip to main content

rust_igraph/algorithms/properties/
reciprocity.rs

1//! Reciprocity (ALGO-PR-004 + ALGO-PR-004b).
2//!
3//! Counterpart of `igraph_reciprocity()` from
4//! `references/igraph/src/properties/basic_properties.c:325-406`.
5//!
6//! For directed graphs, reciprocity is the proportion of mutual
7//! connections — formally `1 - (sum_ij |A_ij - A_ji|) / (2 sum_ij A_ij)`.
8//! Equivalent to (number of edges with a reverse counterpart) / (total
9//! edges). For undirected graphs it is 1.0 by definition. For graphs
10//! with no edges, the value is undefined (`None` here, matching upstream's
11//! `IGRAPH_NAN`).
12//!
13//! [`reciprocity`] — the canonical Phase-1 entry — fixes
14//! `mode = Default` and `ignore_loops = false`.
15//! [`reciprocity_with_mode`] (PR-004b) exposes both knobs.
16
17use crate::core::{Graph, IgraphResult};
18
19/// Reciprocity formula choice. Counterpart of upstream's
20/// `igraph_reciprocity_t` (`IGRAPH_RECIPROCITY_DEFAULT` /
21/// `IGRAPH_RECIPROCITY_RATIO`).
22///
23/// - [`ReciprocityMode::Default`] — `rec / total_edges`. The fraction
24///   of directed edges that have a reverse counterpart.
25/// - [`ReciprocityMode::Ratio`] — `rec / (rec + nonrec)`. The fraction
26///   of *connected ordered vertex pairs* that are reciprocal. The two
27///   formulas agree on graphs with no parallel edges.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum ReciprocityMode {
30    /// `rec / total_edges`.
31    Default,
32    /// `rec / (rec + nonrec)`.
33    Ratio,
34}
35
36/// Reciprocity of `graph`. Returns `None` for graphs with no edges
37/// (matches upstream's `IGRAPH_NAN`).
38///
39/// Counterpart of `igraph_reciprocity(_, _, /*ignore_loops=*/false,
40/// IGRAPH_RECIPROCITY_DEFAULT)`. For undirected graphs returns
41/// `Some(1.0)` unconditionally.
42///
43/// # Examples
44///
45/// ```
46/// use rust_igraph::{Graph, reciprocity};
47///
48/// // Directed mutual pair: 0 -> 1, 1 -> 0. Both edges have a reverse → 1.0.
49/// let mut g = Graph::new(2, true).unwrap();
50/// g.add_edge(0, 1).unwrap();
51/// g.add_edge(1, 0).unwrap();
52/// assert_eq!(reciprocity(&g).unwrap(), Some(1.0));
53///
54/// // One-way: 0 -> 1 only. No reverse → 0.0.
55/// let mut g = Graph::new(2, true).unwrap();
56/// g.add_edge(0, 1).unwrap();
57/// assert_eq!(reciprocity(&g).unwrap(), Some(0.0));
58/// ```
59pub fn reciprocity(graph: &Graph) -> IgraphResult<Option<f64>> {
60    reciprocity_with_mode(graph, false, ReciprocityMode::Default)
61}
62
63/// Reciprocity with explicit mode + `ignore_loops` (ALGO-PR-004b).
64///
65/// Counterpart of `igraph_reciprocity(_, _, ignore_loops, mode)`.
66/// See [`ReciprocityMode`] for the formula.
67///
68/// `ignore_loops`:
69/// - `false` (matches [`reciprocity`]) — self-loops count as mutual
70///   in the numerator (a self-loop is its own reverse) and stay in the
71///   denominator.
72/// - `true` — self-loops drop out of both numerator and denominator.
73///
74/// Returns `None` for graphs with no edges (matches upstream's
75/// `IGRAPH_NAN`), including the `Default` case where `m == 0` and the
76/// `Ratio` case where `rec + nonrec == 0`. Undirected graphs always
77/// return `Some(1.0)`.
78///
79/// # Examples
80///
81/// ```
82/// use rust_igraph::{Graph, reciprocity_with_mode, ReciprocityMode};
83///
84/// // Mutual pair + one-way edge: rec = 2 edges, nonrec = 2 (the
85/// // one-way edge contributes once at source AND once at target).
86/// // Default = 2/3, Ratio = 2/(2+2) = 0.5.
87/// let mut g = Graph::new(3, true).unwrap();
88/// g.add_edge(0, 1).unwrap();
89/// g.add_edge(1, 0).unwrap();
90/// g.add_edge(0, 2).unwrap();
91/// assert_eq!(reciprocity_with_mode(&g, false, ReciprocityMode::Default).unwrap(),
92///            Some(2.0 / 3.0));
93/// assert_eq!(reciprocity_with_mode(&g, false, ReciprocityMode::Ratio).unwrap(),
94///            Some(0.5));
95/// ```
96pub fn reciprocity_with_mode(
97    graph: &Graph,
98    ignore_loops: bool,
99    mode: ReciprocityMode,
100) -> IgraphResult<Option<f64>> {
101    let n = graph.vcount();
102    let m = graph.ecount();
103    if m == 0 {
104        return Ok(None);
105    }
106    if !graph.is_directed() {
107        // Undirected graphs are reciprocal by construction; `ignore_loops`
108        // and `mode` don't change that.
109        return Ok(Some(1.0));
110    }
111
112    // Mirrors upstream's two-pointer merge over sorted in-/out-neighbours
113    // per vertex, classifying matches as reciprocal and mismatches /
114    // leftovers as non-reciprocal.
115    let mut rec: u64 = 0;
116    let mut nonrec: u64 = 0;
117    let mut loops: u64 = 0;
118
119    for v in 0..n {
120        let outneis = graph.out_neighbors_vec(v)?;
121        let inneis = graph.in_neighbors_vec(v)?;
122
123        let mut ip = 0usize;
124        let mut op = 0usize;
125        while ip < inneis.len() && op < outneis.len() {
126            match inneis[ip].cmp(&outneis[op]) {
127                std::cmp::Ordering::Less => {
128                    nonrec += 1;
129                    ip += 1;
130                }
131                std::cmp::Ordering::Greater => {
132                    nonrec += 1;
133                    op += 1;
134                }
135                std::cmp::Ordering::Equal => {
136                    if inneis[ip] == v {
137                        // Self-loop: contributes to `loops` always; only
138                        // joins `rec` if we are not ignoring loops.
139                        loops += 1;
140                        if !ignore_loops {
141                            rec += 1;
142                        }
143                    } else {
144                        rec += 1;
145                    }
146                    ip += 1;
147                    op += 1;
148                }
149            }
150        }
151        // Tail: anything left over in either side is, by definition,
152        // a one-way edge.
153        nonrec += (inneis.len() - ip) as u64 + (outneis.len() - op) as u64;
154    }
155
156    #[allow(clippy::cast_precision_loss)]
157    let result = match mode {
158        ReciprocityMode::Default => {
159            let denom = if ignore_loops {
160                m as u64 - loops
161            } else {
162                m as u64
163            };
164            if denom == 0 {
165                return Ok(None);
166            }
167            rec as f64 / denom as f64
168        }
169        ReciprocityMode::Ratio => {
170            let denom = rec + nonrec;
171            if denom == 0 {
172                return Ok(None);
173            }
174            rec as f64 / denom as f64
175        }
176    };
177    Ok(Some(result))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn empty_graph_returns_none() {
186        let g = Graph::with_vertices(0);
187        assert_eq!(reciprocity(&g).unwrap(), None);
188    }
189
190    #[test]
191    fn isolated_vertices_return_none() {
192        let g = Graph::with_vertices(5);
193        assert_eq!(reciprocity(&g).unwrap(), None);
194    }
195
196    #[test]
197    fn undirected_graph_is_always_1() {
198        let mut g = Graph::with_vertices(3);
199        g.add_edge(0, 1).unwrap();
200        g.add_edge(1, 2).unwrap();
201        assert_eq!(reciprocity(&g).unwrap(), Some(1.0));
202    }
203
204    #[test]
205    fn directed_one_way_edge_has_zero() {
206        let mut g = Graph::new(2, true).unwrap();
207        g.add_edge(0, 1).unwrap();
208        assert_eq!(reciprocity(&g).unwrap(), Some(0.0));
209    }
210
211    #[test]
212    fn directed_mutual_pair_has_one() {
213        let mut g = Graph::new(2, true).unwrap();
214        g.add_edge(0, 1).unwrap();
215        g.add_edge(1, 0).unwrap();
216        assert_eq!(reciprocity(&g).unwrap(), Some(1.0));
217    }
218
219    #[test]
220    fn directed_partial_reciprocity() {
221        // 0 -> 1, 1 -> 0 (mutual), 0 -> 2 (one-way). 3 edges, 2 reciprocal → 2/3.
222        let mut g = Graph::new(3, true).unwrap();
223        g.add_edge(0, 1).unwrap();
224        g.add_edge(1, 0).unwrap();
225        g.add_edge(0, 2).unwrap();
226        let two_thirds = 2.0_f64 / 3.0;
227        assert_eq!(reciprocity(&g).unwrap(), Some(two_thirds));
228    }
229
230    #[test]
231    fn directed_3_cycle_no_reciprocity() {
232        // 0 -> 1 -> 2 -> 0: each edge has no reverse → 0.0.
233        let mut g = Graph::new(3, true).unwrap();
234        g.add_edge(0, 1).unwrap();
235        g.add_edge(1, 2).unwrap();
236        g.add_edge(2, 0).unwrap();
237        assert_eq!(reciprocity(&g).unwrap(), Some(0.0));
238    }
239
240    #[test]
241    fn directed_self_loop_is_counted_as_mutual() {
242        // 0 -> 0 self-loop: 1 edge, mutual → 1.0.
243        let mut g = Graph::new(2, true).unwrap();
244        g.add_edge(0, 0).unwrap();
245        assert_eq!(reciprocity(&g).unwrap(), Some(1.0));
246    }
247
248    // ----- ALGO-PR-004b: ratio mode + ignore_loops -----
249
250    #[test]
251    fn ratio_and_default_diverge_with_one_way_edges() {
252        // 0 → 1, 1 → 0 (mutual), 0 → 2 (one-way).
253        //   m = 3, rec = 2, nonrec = 2 (the one-way 0→2 contributes
254        //                               1 at v=0 and 1 at v=2).
255        //   Default = rec / m         = 2/3 ≈ 0.667
256        //   Ratio   = rec/(rec+nonrec) = 2/4 = 0.5
257        // The two formulas only agree when every edge is mutual
258        // (nonrec == 0 → ratio = rec/rec = 1.0 = m/m = default).
259        let mut g = Graph::new(3, true).unwrap();
260        g.add_edge(0, 1).unwrap();
261        g.add_edge(1, 0).unwrap();
262        g.add_edge(0, 2).unwrap();
263        let two_thirds = 2.0_f64 / 3.0;
264        assert_eq!(
265            reciprocity_with_mode(&g, false, ReciprocityMode::Default).unwrap(),
266            Some(two_thirds)
267        );
268        assert_eq!(
269            reciprocity_with_mode(&g, false, ReciprocityMode::Ratio).unwrap(),
270            Some(0.5)
271        );
272    }
273
274    #[test]
275    fn ratio_and_default_agree_on_fully_mutual_graph() {
276        // Every directed edge has its reverse → rec = m, nonrec = 0.
277        let mut g = Graph::new(3, true).unwrap();
278        for &(u, v) in &[(0u32, 1), (1, 0), (1, 2), (2, 1), (0, 2), (2, 0)] {
279            g.add_edge(u, v).unwrap();
280        }
281        assert_eq!(
282            reciprocity_with_mode(&g, false, ReciprocityMode::Default).unwrap(),
283            Some(1.0)
284        );
285        assert_eq!(
286            reciprocity_with_mode(&g, false, ReciprocityMode::Ratio).unwrap(),
287            Some(1.0)
288        );
289    }
290
291    #[test]
292    fn ignore_loops_drops_self_loop_from_default_denominator() {
293        // 0 → 0 self-loop + 0 → 1 + 1 → 0: m = 3.
294        // Default ignore_loops=false: rec = 3 (loop counted), denom = 3 → 1.0
295        // Default ignore_loops=true:  rec = 2,                 denom = 2 → 1.0
296        // Ratio  ignore_loops=true:   rec = 2, nonrec = 0      → 1.0
297        let mut g = Graph::new(2, true).unwrap();
298        g.add_edge(0, 0).unwrap();
299        g.add_edge(0, 1).unwrap();
300        g.add_edge(1, 0).unwrap();
301        assert_eq!(
302            reciprocity_with_mode(&g, false, ReciprocityMode::Default).unwrap(),
303            Some(1.0)
304        );
305        assert_eq!(
306            reciprocity_with_mode(&g, true, ReciprocityMode::Default).unwrap(),
307            Some(1.0)
308        );
309        assert_eq!(
310            reciprocity_with_mode(&g, true, ReciprocityMode::Ratio).unwrap(),
311            Some(1.0)
312        );
313    }
314
315    #[test]
316    fn ratio_mode_with_one_way_edge() {
317        // 0 → 1 only: nonrec = 2 (both endpoints see one-way), rec = 0.
318        // Ratio = 0 / (0 + 2) = 0.0.
319        let mut g = Graph::new(2, true).unwrap();
320        g.add_edge(0, 1).unwrap();
321        assert_eq!(
322            reciprocity_with_mode(&g, false, ReciprocityMode::Ratio).unwrap(),
323            Some(0.0)
324        );
325    }
326
327    #[test]
328    fn ratio_mode_self_loop_only_with_ignore_loops_returns_none() {
329        // Single self-loop, ignore_loops=true: rec = 0, nonrec = 0
330        // (the self-loop is dropped from rec, and a self-loop produces
331        // no `nonrec` increments because it is matched once on both
332        // sides). Denom = 0 → result undefined → None.
333        let mut g = Graph::new(1, true).unwrap();
334        g.add_edge(0, 0).unwrap();
335        assert_eq!(
336            reciprocity_with_mode(&g, true, ReciprocityMode::Ratio).unwrap(),
337            None
338        );
339    }
340
341    #[test]
342    fn default_mode_ignore_loops_only_loop_returns_none() {
343        // Single self-loop, ignore_loops=true, Default mode:
344        // denom = m - loops = 1 - 1 = 0 → None.
345        let mut g = Graph::new(1, true).unwrap();
346        g.add_edge(0, 0).unwrap();
347        assert_eq!(
348            reciprocity_with_mode(&g, true, ReciprocityMode::Default).unwrap(),
349            None
350        );
351    }
352
353    #[test]
354    fn undirected_unconditional_one() {
355        let mut g = Graph::with_vertices(3);
356        g.add_edge(0, 1).unwrap();
357        g.add_edge(0, 0).unwrap();
358        for &mode in &[ReciprocityMode::Default, ReciprocityMode::Ratio] {
359            for &skip in &[false, true] {
360                assert_eq!(
361                    reciprocity_with_mode(&g, skip, mode).unwrap(),
362                    Some(1.0),
363                    "undirected mode={mode:?} ignore_loops={skip}"
364                );
365            }
366        }
367    }
368
369    #[test]
370    fn ratio_mode_3_cycle_zero() {
371        // 0 → 1 → 2 → 0: rec = 0, nonrec = 6 (each vertex contributes
372        // 1 in-tail + 1 out-tail with no match) → 0.0.
373        let mut g = Graph::new(3, true).unwrap();
374        g.add_edge(0, 1).unwrap();
375        g.add_edge(1, 2).unwrap();
376        g.add_edge(2, 0).unwrap();
377        assert_eq!(
378            reciprocity_with_mode(&g, false, ReciprocityMode::Ratio).unwrap(),
379            Some(0.0)
380        );
381    }
382}