Skip to main content

rust_igraph/algorithms/properties/
constraint.rs

1//! Burt's constraint scores (ALGO-PR-032).
2//!
3//! Counterpart of `igraph_constraint` from
4//! `references/igraph/src/properties/constraint.c` (288 lines).
5//!
6//! Burt's constraint measures how much a vertex's connections are
7//! redundant — higher constraint means fewer structural holes and
8//! less brokerage opportunity.
9
10use crate::core::{Graph, IgraphResult};
11
12/// Compute Burt's constraint scores for all vertices.
13///
14/// The constraint of vertex `i` is defined as:
15///
16/// ```text
17/// C[i] = Σ_j (p[i,j] + Σ_{q ≠ i,j} p[i,q] · p[q,j])²
18/// ```
19///
20/// where the proportional tie strength is:
21///
22/// ```text
23/// p[i,j] = (a[i,j] + a[j,i]) / Σ_{k ≠ i} (a[i,k] + a[k,i])
24/// ```
25///
26/// For isolated vertices (no incident edges excluding self-loops),
27/// the constraint is `NaN`.
28///
29/// Optionally accepts edge weights. If `weights` is `None`, all edges
30/// are treated as having unit weight.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, constraint};
36///
37/// // Star graph: center has constraint from 4 leaves
38/// let mut g = Graph::with_vertices(5);
39/// for i in 1..5 {
40///     g.add_edge(0, i).unwrap();
41/// }
42/// let c = constraint(&g, None).unwrap();
43/// // Center vertex: each leaf contributes (1/4)² = 1/16, total = 4/16 = 0.25
44/// assert!((c[0] - 0.25).abs() < 1e-9);
45/// // Each leaf: only neighbor is center, constraint = 1.0
46/// assert!((c[1] - 1.0).abs() < 1e-9);
47/// ```
48pub fn constraint(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<f64>> {
49    let n = graph.vcount();
50    let n_us = n as usize;
51
52    if let Some(w) = weights {
53        if w.len() != graph.ecount() {
54            return Err(crate::core::IgraphError::InvalidArgument(
55                "weight vector length must equal edge count".to_string(),
56            ));
57        }
58    }
59
60    let strength = compute_strength(graph, weights)?;
61    let mut result = vec![0.0_f64; n_us];
62    let mut contrib = vec![0.0_f64; n_us];
63
64    for i in 0..n {
65        let i_us = i as usize;
66        let in_edges = graph.incident_in(i)?;
67        let out_edges = graph.incident(i)?;
68
69        if in_edges.is_empty() && out_edges.is_empty() {
70            result[i_us] = f64::NAN;
71            continue;
72        }
73
74        clear_contrib(graph, i, &in_edges, &mut contrib)?;
75        if graph.is_directed() {
76            clear_contrib(graph, i, &out_edges, &mut contrib)?;
77        }
78
79        let deg_i = strength[i_us];
80
81        add_direct(graph, i, &in_edges, weights, deg_i, &mut contrib)?;
82        if graph.is_directed() {
83            add_direct(graph, i, &out_edges, weights, deg_i, &mut contrib)?;
84        }
85
86        add_indirect(graph, i, &in_edges, weights, &strength, deg_i, &mut contrib)?;
87        if graph.is_directed() {
88            add_indirect(
89                graph,
90                i,
91                &out_edges,
92                weights,
93                &strength,
94                deg_i,
95                &mut contrib,
96            )?;
97        }
98
99        result[i_us] += square_and_clear(graph, i, &in_edges, &mut contrib)?;
100        if graph.is_directed() {
101            result[i_us] += square_and_clear(graph, i, &out_edges, &mut contrib)?;
102        }
103    }
104
105    Ok(result)
106}
107
108fn clear_contrib(graph: &Graph, i: u32, edges: &[u32], contrib: &mut [f64]) -> IgraphResult<()> {
109    for &eid in edges {
110        let j = graph.edge_other(eid, i)?;
111        contrib[j as usize] = 0.0;
112    }
113    Ok(())
114}
115
116fn add_direct(
117    graph: &Graph,
118    i: u32,
119    edges: &[u32],
120    weights: Option<&[f64]>,
121    deg_i: f64,
122    contrib: &mut [f64],
123) -> IgraphResult<()> {
124    for &eid in edges {
125        let j = graph.edge_other(eid, i)?;
126        if i != j {
127            contrib[j as usize] += edge_weight(weights, eid) / deg_i;
128        }
129    }
130    Ok(())
131}
132
133fn add_indirect(
134    graph: &Graph,
135    i: u32,
136    edges: &[u32],
137    weights: Option<&[f64]>,
138    strength: &[f64],
139    deg_i: f64,
140    contrib: &mut [f64],
141) -> IgraphResult<()> {
142    for &eid in edges {
143        let j = graph.edge_other(eid, i)?;
144        if i == j {
145            continue;
146        }
147        let w_ij = edge_weight(weights, eid);
148        let deg_j = strength[j as usize];
149        let factor = w_ij / (deg_i * deg_j);
150        let j_in = graph.incident_in(j)?;
151        for &eid2 in &j_in {
152            let q = graph.edge_other(eid2, j)?;
153            if j != q {
154                contrib[q as usize] += factor * edge_weight(weights, eid2);
155            }
156        }
157        if graph.is_directed() {
158            let j_out = graph.incident(j)?;
159            for &eid2 in &j_out {
160                let q = graph.edge_other(eid2, j)?;
161                if j != q {
162                    contrib[q as usize] += factor * edge_weight(weights, eid2);
163                }
164            }
165        }
166    }
167    Ok(())
168}
169
170fn square_and_clear(
171    graph: &Graph,
172    i: u32,
173    edges: &[u32],
174    contrib: &mut [f64],
175) -> IgraphResult<f64> {
176    let mut sum = 0.0;
177    for &eid in edges {
178        let j = graph.edge_other(eid, i)?;
179        if i != j {
180            sum += contrib[j as usize] * contrib[j as usize];
181            contrib[j as usize] = 0.0;
182        }
183    }
184    Ok(sum)
185}
186
187fn edge_weight(weights: Option<&[f64]>, eid: u32) -> f64 {
188    weights.map_or(1.0, |w| w[eid as usize])
189}
190
191/// Compute weighted strength (sum of edge weights) for all vertices,
192/// excluding self-loops. Uses `IGRAPH_ALL` mode (in + out for directed).
193fn compute_strength(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<f64>> {
194    let n = graph.vcount();
195    let n_us = n as usize;
196    let mut strength = vec![0.0_f64; n_us];
197
198    for v in 0..n {
199        let v_us = v as usize;
200        let in_edges = graph.incident_in(v)?;
201        for &eid in &in_edges {
202            let other = graph.edge_other(eid, v)?;
203            if other != v {
204                strength[v_us] += edge_weight(weights, eid);
205            }
206        }
207        if graph.is_directed() {
208            let out_edges = graph.incident(v)?;
209            for &eid in &out_edges {
210                let other = graph.edge_other(eid, v)?;
211                if other != v {
212                    strength[v_us] += edge_weight(weights, eid);
213                }
214            }
215        }
216    }
217
218    Ok(strength)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::create;
225
226    fn approx_eq(a: f64, b: f64) -> bool {
227        (a - b).abs() < 1e-9
228    }
229
230    #[test]
231    fn empty_graph() {
232        let g = Graph::with_vertices(0);
233        let c = constraint(&g, None).expect("ok");
234        assert!(c.is_empty());
235    }
236
237    #[test]
238    fn isolated_vertex_is_nan() {
239        let g = Graph::with_vertices(3);
240        let c = constraint(&g, None).expect("ok");
241        assert!(c[0].is_nan());
242        assert!(c[1].is_nan());
243        assert!(c[2].is_nan());
244    }
245
246    #[test]
247    fn single_edge() {
248        // 0-1: each vertex has one neighbor, constraint = 1.0
249        let g = create(&[(0, 1)], 2, false).expect("ok");
250        let c = constraint(&g, None).expect("ok");
251        assert!(approx_eq(c[0], 1.0));
252        assert!(approx_eq(c[1], 1.0));
253    }
254
255    #[test]
256    fn triangle() {
257        // 0-1, 1-2, 0-2: each has 2 neighbors connected to each other
258        // p[i,j] = 1/2 for each neighbor
259        // indirect: p[i,q]*p[q,j] = (1/2)*(1/2) = 1/4 for the single
260        // intermediary
261        // total for neighbor j: p[i,j] + sum_q p[i,q]*p[q,j] = 1/2 + 1/4 = 3/4
262        // constraint = 2 * (3/4)^2 = 2 * 9/16 = 9/8 = 1.125
263        let g = create(&[(0, 1), (1, 2), (0, 2)], 3, false).expect("ok");
264        let c = constraint(&g, None).expect("ok");
265        assert!(approx_eq(c[0], 1.125), "got {}", c[0]);
266        assert!(approx_eq(c[1], 1.125), "got {}", c[1]);
267        assert!(approx_eq(c[2], 1.125), "got {}", c[2]);
268    }
269
270    #[test]
271    fn star_4_leaves() {
272        // Center 0 connected to 1,2,3,4. Leaves not connected to each other.
273        // Center: 4 neighbors, p[0,j] = 1/4 each, no indirect paths
274        //   (leaves not connected), constraint = 4*(1/4)^2 = 4/16 = 0.25
275        // Leaf k: 1 neighbor (center), p[k,0] = 1, constraint = 1^2 = 1.0
276        let g = create(&[(0, 1), (0, 2), (0, 3), (0, 4)], 5, false).expect("ok");
277        let c = constraint(&g, None).expect("ok");
278        assert!(approx_eq(c[0], 0.25), "center: {}", c[0]);
279        for (i, &val) in c.iter().enumerate().skip(1) {
280            assert!(approx_eq(val, 1.0), "leaf {i}: {val}");
281        }
282    }
283
284    #[test]
285    fn path_3_vertices() {
286        // 0-1-2: vertex 1 bridges 0 and 2
287        // Vertex 0: p[0,1] = 1.0, no indirect path to 1, constraint = 1.0
288        // Vertex 2: p[2,1] = 1.0, constraint = 1.0
289        // Vertex 1: p[1,0] = 1/2, p[1,2] = 1/2, no indirect path between
290        //   0 and 2, constraint = (1/2)^2 + (1/2)^2 = 1/2 = 0.5
291        let g = create(&[(0, 1), (1, 2)], 3, false).expect("ok");
292        let c = constraint(&g, None).expect("ok");
293        assert!(approx_eq(c[0], 1.0), "v0: {}", c[0]);
294        assert!(approx_eq(c[1], 0.5), "v1: {}", c[1]);
295        assert!(approx_eq(c[2], 1.0), "v2: {}", c[2]);
296    }
297
298    #[test]
299    fn self_loop_excluded() {
300        // 0-0 (self-loop), 0-1
301        let g = create(&[(0, 0), (0, 1)], 2, false).expect("ok");
302        let c = constraint(&g, None).expect("ok");
303        // Self-loop doesn't count toward strength or constraint
304        assert!(approx_eq(c[0], 1.0), "v0: {}", c[0]);
305        assert!(approx_eq(c[1], 1.0), "v1: {}", c[1]);
306    }
307
308    #[test]
309    fn weighted_single_edge() {
310        let g = create(&[(0, 1)], 2, false).expect("ok");
311        let c = constraint(&g, Some(&[3.0])).expect("ok");
312        assert!(approx_eq(c[0], 1.0));
313        assert!(approx_eq(c[1], 1.0));
314    }
315
316    #[test]
317    fn weighted_path_3() {
318        // 0-1 (w=1), 1-2 (w=3)
319        // strength: v0=1, v1=1+3=4, v2=3
320        // v0: p[0,1] = 1/1 = 1.0 → constraint = 1.0
321        // v2: p[2,1] = 3/3 = 1.0 → constraint = 1.0
322        // v1: p[1,0] = 1/4, p[1,2] = 3/4
323        //   no indirect paths → constraint = (1/4)^2 + (3/4)^2 = 1/16 + 9/16 = 10/16 = 0.625
324        let g = create(&[(0, 1), (1, 2)], 3, false).expect("ok");
325        let c = constraint(&g, Some(&[1.0, 3.0])).expect("ok");
326        assert!(approx_eq(c[0], 1.0), "v0: {}", c[0]);
327        assert!(approx_eq(c[1], 0.625), "v1: {}", c[1]);
328        assert!(approx_eq(c[2], 1.0), "v2: {}", c[2]);
329    }
330
331    #[test]
332    fn invalid_weight_length() {
333        let g = create(&[(0, 1)], 2, false).expect("ok");
334        let r = constraint(&g, Some(&[1.0, 2.0]));
335        assert!(r.is_err());
336    }
337
338    #[test]
339    fn mixed_connected_isolated() {
340        // 0-1, 2 isolated
341        let g = create(&[(0, 1)], 3, false).expect("ok");
342        let c = constraint(&g, None).expect("ok");
343        assert!(approx_eq(c[0], 1.0));
344        assert!(approx_eq(c[1], 1.0));
345        assert!(c[2].is_nan());
346    }
347
348    #[test]
349    fn k4_complete() {
350        // K4: every vertex has 3 neighbors, all connected
351        // p[i,j] = 1/3 for each neighbor
352        // indirect through q: p[i,q]*p[q,j] = (1/3)*(1/3) = 1/9
353        // two intermediaries: 2 * 1/9 = 2/9
354        // total per neighbor: 1/3 + 2/9 = 3/9 + 2/9 = 5/9
355        // constraint = 3 * (5/9)^2 = 3 * 25/81 = 75/81 = 25/27
356        let g = create(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], 4, false).expect("ok");
357        let c = constraint(&g, None).expect("ok");
358        let expected = 25.0 / 27.0;
359        for (i, &val) in c.iter().enumerate() {
360            assert!(approx_eq(val, expected), "v{i}: {val} expected {expected}");
361        }
362    }
363
364    #[test]
365    fn directed_path() {
366        // 0->1->2 directed
367        // strength (ALL, no loops):
368        //   v0: out-degree to 1 = 1, in-degree = 0, strength = 1
369        //   v1: out to 2 = 1, in from 0 = 1, strength = 2
370        //   v2: in from 1 = 1, out = 0, strength = 1
371        // v0: neighbor set = {1} (out-edge 0->1)
372        //   p[0,1] = 1/1 = 1.0 → constraint = 1.0
373        // v2: neighbor set = {1} (in-edge 1->2)
374        //   p[2,1] = 1/1 = 1.0 → constraint = 1.0
375        // v1: neighbors = {0, 2} (in-edge from 0, out-edge to 2)
376        //   p[1,0] = 1/2, p[1,2] = 1/2
377        //   no indirect paths → constraint = (1/2)^2 + (1/2)^2 = 0.5
378        let g = create(&[(0, 1), (1, 2)], 3, true).expect("ok");
379        let c = constraint(&g, None).expect("ok");
380        assert!(approx_eq(c[0], 1.0), "v0: {}", c[0]);
381        assert!(approx_eq(c[1], 0.5), "v1: {}", c[1]);
382        assert!(approx_eq(c[2], 1.0), "v2: {}", c[2]);
383    }
384}
385
386#[cfg(all(test, feature = "proptest-harness"))]
387mod proptests {
388    use super::*;
389    use crate::create;
390    use proptest::prelude::*;
391
392    fn arb_graph(max_v: u32) -> impl Strategy<Value = Graph> {
393        (2..=max_v).prop_flat_map(|n| {
394            let max_e = (n as usize)
395                .saturating_mul(n.saturating_sub(1) as usize)
396                .min(20);
397            proptest::collection::vec((0..n, 0..n), 0..=max_e).prop_map(move |edges| {
398                let edge_tuples: Vec<(u32, u32)> = edges.into_iter().collect();
399                create(&edge_tuples, n, false).expect("arb graph")
400            })
401        })
402    }
403
404    proptest! {
405        #[test]
406        fn constraint_non_negative_or_nan(g in arb_graph(8)) {
407            let c = constraint(&g, None).expect("ok");
408            for (i, &val) in c.iter().enumerate() {
409                prop_assert!(
410                    val.is_nan() || val >= 0.0,
411                    "negative constraint {val} at vertex {i}"
412                );
413            }
414        }
415
416        #[test]
417        fn isolated_vertex_is_nan_prop(n in 1u32..10) {
418            let g = Graph::with_vertices(n);
419            let c = constraint(&g, None).expect("ok");
420            for (i, &val) in c.iter().enumerate() {
421                prop_assert!(val.is_nan(), "expected NaN for isolated vertex {i}, got {val}");
422            }
423        }
424
425        #[test]
426        fn unit_weights_match_unweighted(g in arb_graph(8)) {
427            let ne = g.ecount();
428            let unw = constraint(&g, None).expect("ok");
429            let w = constraint(&g, Some(&vec![1.0; ne])).expect("ok");
430            for (i, (&a, &b)) in unw.iter().zip(w.iter()).enumerate() {
431                if a.is_nan() {
432                    prop_assert!(b.is_nan(), "v{i}: unweighted NaN but weighted {b}");
433                } else {
434                    prop_assert!(
435                        (a - b).abs() < 1e-9,
436                        "v{i}: unweighted {a} != weighted {b}"
437                    );
438                }
439            }
440        }
441    }
442}