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}