Skip to main content

rust_igraph/algorithms/games/
watts.rs

1//! Watts–Strogatz small-world model (ALGO-GN-009).
2//!
3//! Counterpart of `igraph_watts_strogatz_game()` in
4//! `references/igraph/src/games/watts_strogatz.c:75-118` for the
5//! `dim = 1` case (1-D periodic ring) — by far the dominant use case
6//! and the original Watts & Strogatz Nature '98 model. The upstream
7//! C entry point delegates to `igraph_square_lattice` plus
8//! `igraph_rewire_edges`; both helpers are folded directly into this
9//! module rather than ported as standalone APIs.
10//!
11//! ## Model
12//!
13//! Step 1: build a periodic 1-D ring on `size` vertices where every
14//! vertex is connected to the `nei` nearest neighbours on each side.
15//! Each vertex therefore has degree `2·nei` exactly (the validation
16//! step rejects `2·nei + 1 > size`, so the ring is always "wide
17//! enough" that the two sides do not overlap).
18//!
19//! Step 2: walk the edge list and, independently for each endpoint of
20//! each edge, rewire with probability `p`. The new endpoint is sampled
21//! uniformly from the vertex set with the optional rejection rules:
22//!   * `loops = false` — a candidate equal to the other endpoint of
23//!     the same edge is rejected, mirroring upstream's "draw from
24//!     `[0, n-2]` and remap to skip the kept endpoint" trick;
25//!   * `multiple = false` — a candidate that would create a duplicate
26//!     of an existing edge is rejected. The C reference uses a custom
27//!     linked-list adjacency for O(1) duplicate detection; we use a
28//!     `HashSet<(u32, u32)>` of canonical pairs for the same effect.
29//!
30//! ## Validation
31//!
32//! * `size ≥ 1`.
33//! * `nei ≥ 0`.
34//! * `2·nei + 1 ≤ size` (so the ring lattice has no overlap; for
35//!   `nei = 0` this collapses to `size ≥ 1` which is already required).
36//! * `p ∈ [0, 1]`, NaN / infinity rejected.
37//!
38//! ## Determinism
39//!
40//! Fully deterministic in `(size, nei, p, loops, multiple, seed)` via
41//! `SplitMix64`.
42//!
43//! ## Reference
44//!
45//! Duncan J. Watts and Steven H. Strogatz, *Collective dynamics of
46//! "small-world" networks*, Nature **393**, 440-442 (1998).
47
48#![allow(
49    clippy::cast_possible_truncation,
50    clippy::cast_sign_loss,
51    clippy::many_single_char_names,
52    clippy::similar_names
53)]
54
55use std::collections::HashSet;
56
57use crate::core::rng::SplitMix64;
58use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
59
60fn validate(size: u32, nei: u32, p: f64) -> IgraphResult<()> {
61    if size == 0 {
62        return Err(IgraphError::InvalidArgument(
63            "watts_strogatz_game: size must be at least 1".into(),
64        ));
65    }
66    if !p.is_finite() || !(0.0..=1.0).contains(&p) {
67        return Err(IgraphError::InvalidArgument(
68            "watts_strogatz_game: p must be a finite real in [0, 1]".into(),
69        ));
70    }
71    // 2·nei + 1 ≤ size ⇔ 2·nei ≤ size − 1. We compute on u64 to dodge
72    // 32-bit overflow even when nei is u32::MAX.
73    let two_nei = u64::from(nei).saturating_mul(2);
74    if two_nei + 1 > u64::from(size) {
75        return Err(IgraphError::InvalidArgument(format!(
76            "watts_strogatz_game: 2*nei + 1 = {} must not exceed size = {}",
77            two_nei + 1,
78            size,
79        )));
80    }
81    Ok(())
82}
83
84/// Build the 1-D periodic ring with `nei`-radius neighbourhood, in
85/// edge-list form. Each vertex `v` emits `nei` forward edges to
86/// `(v + j) mod size` for `j = 1..=nei`. Total edges = `size · nei`.
87fn build_ring_lattice_edges(size: u32, nei: u32) -> Vec<(u32, u32)> {
88    let n = size as usize;
89    let k = nei as usize;
90    let mut edges: Vec<(u32, u32)> = Vec::with_capacity(n * k);
91    for v in 0..size {
92        for j in 1..=nei {
93            let u = (v + j) % size;
94            edges.push((v, u));
95        }
96    }
97    edges
98}
99
100/// Canonical undirected pair: smaller vertex first.
101#[inline]
102fn canonical(a: u32, b: u32) -> (u32, u32) {
103    if a <= b { (a, b) } else { (b, a) }
104}
105
106/// Draw a uniform random vertex in `[0, size)` that is not equal to
107/// `forbidden` (when `loops == false`). The reduction trick — draw
108/// from `[0, size-1)` and remap the forbidden value to `size-1` —
109/// matches `references/igraph/src/operators/rewire_edges.c:111-116`
110/// and yields a uniform distribution over `[0, size) \ {forbidden}`
111/// in O(1).
112#[inline]
113fn draw_vertex(size: u32, forbidden: u32, loops: bool, rng: &mut SplitMix64) -> u32 {
114    if loops {
115        rng.gen_index(size as usize) as u32
116    } else {
117        let r = rng.gen_index((size - 1) as usize) as u32;
118        if r == forbidden { size - 1 } else { r }
119    }
120}
121
122/// Rewire a single endpoint of edge `eid`. `which == 0` rewires the
123/// "from" endpoint; `which == 1` rewires the "to" endpoint.
124///
125/// `adj`, when present, holds canonical undirected pairs already
126/// represented in the edge list. With `adj == None` the multi-edge
127/// constraint is skipped (the `multiple == true` fast path).
128fn rewire_one_endpoint(
129    eid: usize,
130    which: u8,
131    edges: &mut [(u32, u32)],
132    adj: Option<&mut HashSet<(u32, u32)>>,
133    size: u32,
134    loops: bool,
135    rng: &mut SplitMix64,
136) {
137    let (a, b) = edges[eid];
138    let (old_v, kept) = if which == 0 { (a, b) } else { (b, a) };
139
140    if let Some(set) = adj.as_ref() {
141        debug_assert!(set.contains(&canonical(a, b)));
142    }
143
144    if let Some(set) = adj {
145        // No-multi-edge path. Remove the current edge from the
146        // canonical set before drawing so a candidate equal to old_v
147        // (no-op move) is also legal.
148        set.remove(&canonical(a, b));
149
150        // Bounded rejection: at most `size` candidates exist, and at
151        // least one — old_v itself — is always feasible (kept != old_v
152        // unless it's a self-loop and self-loops are allowed, in
153        // which case (kept, kept) was removed above). So the loop
154        // terminates in expected O(1 / (1 − density)).
155        let mut tries = 0u32;
156        let cap = size.saturating_mul(4).max(64);
157        let pick = loop {
158            let cand = draw_vertex(size, kept, loops, rng);
159            let pair = canonical(cand, kept);
160            if !set.contains(&pair) {
161                break cand;
162            }
163            tries += 1;
164            if tries >= cap {
165                // Density too high to find a fresh vertex within the
166                // budget — keep the original endpoint. Same fallback
167                // shape as the C `while (… && pot != v)` loop, which
168                // also yields `pot == v` (no rewrite) on saturation.
169                break old_v;
170            }
171        };
172        let new_pair = canonical(pick, kept);
173        set.insert(new_pair);
174        if which == 0 {
175            edges[eid] = (pick, kept);
176        } else {
177            edges[eid] = (kept, pick);
178        }
179    } else {
180        // Multi-edges allowed: single uniform draw, no rejection.
181        let pick = draw_vertex(size, kept, loops, rng);
182        if which == 0 {
183            edges[eid] = (pick, kept);
184        } else {
185            edges[eid] = (kept, pick);
186        }
187    }
188}
189
190/// Generate a 1-D Watts–Strogatz small-world graph.
191///
192/// Builds a periodic ring lattice of `size` vertices where every
193/// vertex connects to its `nei` nearest neighbours on each side
194/// (total degree `2·nei`), then independently rewires each endpoint
195/// of each edge with probability `p`. The candidate replacement is
196/// uniformly sampled from the vertex set, optionally subject to a
197/// no-self-loop / no-multi-edge rejection rule.
198///
199/// `loops = false` rejects any candidate equal to the kept endpoint,
200/// `multiple = false` additionally rejects candidates that would
201/// duplicate an existing edge. Both default to `false` in the canonical
202/// Watts–Strogatz Nature '98 model and in python-igraph's
203/// `Graph.Watts_Strogatz`. The output is always undirected.
204///
205/// # Errors
206///
207/// Returns `IgraphError::InvalidArgument` if:
208/// * `size == 0`, or
209/// * `2·nei + 1 > size` (the ring would self-overlap), or
210/// * `p` is NaN, infinite, or outside `[0, 1]`.
211///
212/// # Examples
213///
214/// ```
215/// use rust_igraph::watts_strogatz_game;
216/// let g = watts_strogatz_game(10, 2, 0.0, false, false, 42).unwrap();
217/// assert_eq!(g.vcount(), 10);
218/// // p = 0 leaves the lattice untouched: 10 vertices × 2 neighbours
219/// // per side / 2 (undirected) × 2 sides = 20 edges.
220/// assert_eq!(g.ecount(), 20);
221/// ```
222pub fn watts_strogatz_game(
223    size: u32,
224    nei: u32,
225    p: f64,
226    loops: bool,
227    multiple: bool,
228    seed: u64,
229) -> IgraphResult<Graph> {
230    validate(size, nei, p)?;
231
232    let mut edges = build_ring_lattice_edges(size, nei);
233    let m = edges.len();
234
235    // p == 0 short-circuit: no rewire, ring stays intact.
236    if p > 0.0 && m > 0 {
237        let mut rng = SplitMix64::new(seed);
238
239        // Build adjacency set for the no-multi-edge path.
240        let mut adj_set: Option<HashSet<(u32, u32)>> = if multiple {
241            None
242        } else {
243            let mut s = HashSet::with_capacity(m);
244            for &(a, b) in &edges {
245                s.insert(canonical(a, b));
246            }
247            Some(s)
248        };
249
250        // Pass 1: rewire the "from" endpoint of each edge with
251        // probability p, using geometric skips per upstream's
252        // `references/igraph/src/operators/rewire_edges.c:99-129`.
253        let mut to_rewire = rng.gen_geom(p);
254        while to_rewire.is_finite() && (to_rewire as usize) < m {
255            let eid = to_rewire as usize;
256            rewire_one_endpoint(eid, 0, &mut edges, adj_set.as_mut(), size, loops, &mut rng);
257            to_rewire += rng.gen_geom(p) + 1.0;
258        }
259
260        // Pass 2: rewire the "to" endpoint of each edge.
261        let mut to_rewire = rng.gen_geom(p);
262        while to_rewire.is_finite() && (to_rewire as usize) < m {
263            let eid = to_rewire as usize;
264            rewire_one_endpoint(eid, 1, &mut edges, adj_set.as_mut(), size, loops, &mut rng);
265            to_rewire += rng.gen_geom(p) + 1.0;
266        }
267    }
268
269    let mut g = Graph::with_vertices(size);
270    let edges_iter = edges
271        .into_iter()
272        .map(|(a, b)| (a as VertexId, b as VertexId));
273    g.add_edges(edges_iter)?;
274    Ok(g)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::collections::HashSet;
281
282    fn count_unique_edges(g: &Graph) -> usize {
283        let mut canonical: HashSet<(u32, u32)> = HashSet::with_capacity(g.ecount());
284        let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
285        for eid in 0..m {
286            let (a, b) = g.edge(eid).expect("edge id in bounds");
287            canonical.insert(if a <= b { (a, b) } else { (b, a) });
288        }
289        canonical.len()
290    }
291
292    fn degrees(g: &Graph) -> Vec<u64> {
293        let mut d = vec![0u64; g.vcount() as usize];
294        let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
295        for eid in 0..m {
296            let (a, b) = g.edge(eid).expect("edge id in bounds");
297            d[a as usize] += 1;
298            d[b as usize] += 1;
299        }
300        d
301    }
302
303    #[test]
304    fn ring_lattice_p0_undirected_undirected_count() {
305        // p = 0 ⇒ unchanged ring. size = 8, nei = 2 → 8·2 = 16 edges,
306        // every vertex has degree 4.
307        let g = watts_strogatz_game(8, 2, 0.0, false, false, 1).unwrap();
308        assert_eq!(g.vcount(), 8);
309        assert_eq!(g.ecount(), 16);
310        assert!(!g.is_directed());
311        let d = degrees(&g);
312        for (v, deg) in d.iter().enumerate() {
313            assert_eq!(*deg, 4, "vertex {v} has degree {deg}, expected 4");
314        }
315    }
316
317    #[test]
318    fn ring_lattice_nei_zero_is_edgeless() {
319        let g = watts_strogatz_game(10, 0, 0.5, false, false, 7).unwrap();
320        assert_eq!(g.vcount(), 10);
321        assert_eq!(g.ecount(), 0);
322    }
323
324    #[test]
325    fn p_one_rewires_every_endpoint_undirected() {
326        // p = 1 means *every* endpoint is rewired. Edge count is
327        // preserved (rewire never adds or drops edges, only mutates
328        // endpoints). With multiple=false, the result is still simple.
329        let g = watts_strogatz_game(20, 3, 1.0, false, false, 0x_C0FFEE).unwrap();
330        assert_eq!(g.vcount(), 20);
331        assert_eq!(g.ecount(), 20 * 3); // 60 edges = size·nei
332        let n_unique = count_unique_edges(&g);
333        assert_eq!(
334            n_unique,
335            g.ecount(),
336            "rewire should not introduce multi-edges"
337        );
338        for eid in 0..u32::try_from(g.ecount()).unwrap() {
339            let (a, b) = g.edge(eid).unwrap();
340            assert_ne!(a, b, "no self-loops when loops=false (edge {eid})");
341        }
342    }
343
344    #[test]
345    fn rewire_preserves_edge_count() {
346        for &p in &[0.0, 0.1, 0.3, 0.5, 0.9, 1.0] {
347            let g = watts_strogatz_game(50, 3, p, false, false, 0xABCD).unwrap();
348            assert_eq!(
349                g.ecount(),
350                50 * 3,
351                "edge count should equal size·nei at p={p}"
352            );
353        }
354    }
355
356    #[test]
357    fn simple_output_has_no_loops_or_multi() {
358        let g = watts_strogatz_game(30, 4, 0.5, false, false, 0xDEAD).unwrap();
359        let n_unique = count_unique_edges(&g);
360        assert_eq!(n_unique, g.ecount(), "no multi-edges");
361        for eid in 0..u32::try_from(g.ecount()).unwrap() {
362            let (a, b) = g.edge(eid).unwrap();
363            assert_ne!(a, b, "no self-loops");
364        }
365    }
366
367    #[test]
368    fn multigraph_path_runs_without_panic() {
369        // multiple = true → no rejection; loops = true → self-loops OK.
370        let g = watts_strogatz_game(20, 2, 0.7, true, true, 0xBEEF).unwrap();
371        assert_eq!(g.vcount(), 20);
372        assert_eq!(g.ecount(), 20 * 2);
373    }
374
375    #[test]
376    fn loops_true_multiple_false_may_self_loop() {
377        // Carve a regime where the candidate-equal-to-kept-endpoint
378        // case is reachable. We don't assert that a self-loop *exists*
379        // (it depends on RNG) but the run must succeed.
380        let g = watts_strogatz_game(10, 1, 1.0, true, false, 0x1357).unwrap();
381        assert_eq!(g.ecount(), 10);
382        // is_simple may be false here, but every edge must be unique
383        // because multiple=false still rejects duplicates.
384        let n_unique = count_unique_edges(&g);
385        assert_eq!(n_unique, g.ecount());
386    }
387
388    #[test]
389    fn determinism_same_seed_same_graph() {
390        let a = watts_strogatz_game(30, 3, 0.2, false, false, 123_456).unwrap();
391        let b = watts_strogatz_game(30, 3, 0.2, false, false, 123_456).unwrap();
392        assert_eq!(a.vcount(), b.vcount());
393        assert_eq!(a.ecount(), b.ecount());
394        let ea: Vec<(u32, u32)> = (0..u32::try_from(a.ecount()).unwrap())
395            .map(|eid| {
396                let (u, v) = a.edge(eid).unwrap();
397                if u <= v { (u, v) } else { (v, u) }
398            })
399            .collect();
400        let eb: Vec<(u32, u32)> = (0..u32::try_from(b.ecount()).unwrap())
401            .map(|eid| {
402                let (u, v) = b.edge(eid).unwrap();
403                if u <= v { (u, v) } else { (v, u) }
404            })
405            .collect();
406        let mut sa = ea.clone();
407        sa.sort_unstable();
408        let mut sb = eb.clone();
409        sb.sort_unstable();
410        assert_eq!(sa, sb);
411    }
412
413    #[test]
414    fn different_seeds_diverge() {
415        let a = watts_strogatz_game(30, 3, 0.5, false, false, 1).unwrap();
416        let b = watts_strogatz_game(30, 3, 0.5, false, false, 2).unwrap();
417        let ea: HashSet<(u32, u32)> = (0..u32::try_from(a.ecount()).unwrap())
418            .map(|eid| {
419                let (u, v) = a.edge(eid).unwrap();
420                if u <= v { (u, v) } else { (v, u) }
421            })
422            .collect();
423        let eb: HashSet<(u32, u32)> = (0..u32::try_from(b.ecount()).unwrap())
424            .map(|eid| {
425                let (u, v) = b.edge(eid).unwrap();
426                if u <= v { (u, v) } else { (v, u) }
427            })
428            .collect();
429        assert_ne!(
430            ea, eb,
431            "different seeds should yield different rewired graphs"
432        );
433    }
434
435    #[test]
436    fn size_zero_rejected() {
437        let err = watts_strogatz_game(0, 0, 0.0, false, false, 0).unwrap_err();
438        match err {
439            IgraphError::InvalidArgument(msg) => assert!(msg.contains("size")),
440            other => panic!("unexpected error: {other:?}"),
441        }
442    }
443
444    #[test]
445    fn nei_too_large_rejected() {
446        // 2·nei + 1 > size: nei = 3, size = 6 → 7 > 6.
447        let err = watts_strogatz_game(6, 3, 0.0, false, false, 0).unwrap_err();
448        match err {
449            IgraphError::InvalidArgument(msg) => assert!(msg.contains("nei")),
450            other => panic!("unexpected error: {other:?}"),
451        }
452    }
453
454    #[test]
455    fn p_out_of_range_rejected() {
456        for &p in &[-0.1_f64, 1.1, f64::NAN, f64::INFINITY] {
457            let err = watts_strogatz_game(10, 2, p, false, false, 0).unwrap_err();
458            matches!(err, IgraphError::InvalidArgument(_));
459        }
460    }
461
462    #[test]
463    fn ring_lattice_2_nei_equals_size_minus_one_is_almost_complete() {
464        // size = 5, nei = 2 → 2·nei + 1 = 5 = size: the ring lattice
465        // becomes K_5 (every vertex connects to the other 4).
466        let g = watts_strogatz_game(5, 2, 0.0, false, false, 0).unwrap();
467        assert_eq!(g.ecount(), 10); // C(5, 2) = 10
468        let d = degrees(&g);
469        for deg in d {
470            assert_eq!(deg, 4);
471        }
472    }
473
474    #[test]
475    fn p_zero_seed_independence() {
476        let a = watts_strogatz_game(20, 2, 0.0, false, false, 1).unwrap();
477        let b = watts_strogatz_game(20, 2, 0.0, false, false, 999).unwrap();
478        // p=0 means the seed should not be touched at all → identical
479        // graphs regardless of seed.
480        assert_eq!(a.ecount(), b.ecount());
481        for eid in 0..u32::try_from(a.ecount()).unwrap() {
482            assert_eq!(a.edge(eid).unwrap(), b.edge(eid).unwrap());
483        }
484    }
485}
486
487#[cfg(all(test, feature = "proptest-harness"))]
488mod proptests {
489    use super::*;
490    use proptest::prelude::*;
491    use std::collections::HashSet;
492
493    fn canonical_set(g: &Graph) -> HashSet<(u32, u32)> {
494        let mut s = HashSet::with_capacity(g.ecount());
495        let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
496        for eid in 0..m {
497            let (a, b) = g.edge(eid).expect("edge id in bounds");
498            s.insert(if a <= b { (a, b) } else { (b, a) });
499        }
500        s
501    }
502
503    proptest! {
504        #![proptest_config(ProptestConfig {
505            cases: 64, .. ProptestConfig::default()
506        })]
507
508        #[test]
509        fn invariant_simple_graph_no_loops_no_multi(
510            size in 4u32..30,
511            nei_raw in 1u32..5,
512            p in 0.0_f64..=1.0,
513            seed in any::<u64>(),
514        ) {
515            // Ensure 2·nei + 1 ≤ size.
516            let nei = std::cmp::min(nei_raw, (size - 1) / 2);
517            prop_assume!(nei >= 1);
518
519            let g = watts_strogatz_game(size, nei, p, false, false, seed).unwrap();
520            prop_assert_eq!(g.vcount(), size);
521            prop_assert_eq!(g.ecount(), (size * nei) as usize);
522
523            // No self-loops, no multi-edges.
524            let canonical = canonical_set(&g);
525            prop_assert_eq!(canonical.len(), g.ecount());
526            for eid in 0..u32::try_from(g.ecount()).unwrap() {
527                let (a, b) = g.edge(eid).unwrap();
528                prop_assert_ne!(a, b);
529            }
530        }
531
532        #[test]
533        fn invariant_edge_count_preserved_under_any_p(
534            size in 4u32..30,
535            nei_raw in 1u32..5,
536            p in 0.0_f64..=1.0,
537            loops in any::<bool>(),
538            multiple in any::<bool>(),
539            seed in any::<u64>(),
540        ) {
541            let nei = std::cmp::min(nei_raw, (size - 1) / 2);
542            prop_assume!(nei >= 1);
543            let g = watts_strogatz_game(size, nei, p, loops, multiple, seed).unwrap();
544            prop_assert_eq!(g.ecount(), (size * nei) as usize);
545            prop_assert_eq!(g.vcount(), size);
546        }
547
548        #[test]
549        fn invariant_determinism(
550            size in 4u32..30,
551            nei_raw in 1u32..5,
552            p in 0.0_f64..=1.0,
553            loops in any::<bool>(),
554            multiple in any::<bool>(),
555            seed in any::<u64>(),
556        ) {
557            let nei = std::cmp::min(nei_raw, (size - 1) / 2);
558            prop_assume!(nei >= 1);
559            let a = watts_strogatz_game(size, nei, p, loops, multiple, seed).unwrap();
560            let b = watts_strogatz_game(size, nei, p, loops, multiple, seed).unwrap();
561            prop_assert_eq!(a.vcount(), b.vcount());
562            prop_assert_eq!(a.ecount(), b.ecount());
563
564            let mut ea: Vec<(u32, u32)> = (0..u32::try_from(a.ecount()).unwrap())
565                .map(|eid| {
566                    let (u, v) = a.edge(eid).unwrap();
567                    if u <= v { (u, v) } else { (v, u) }
568                })
569                .collect();
570            let mut eb: Vec<(u32, u32)> = (0..u32::try_from(b.ecount()).unwrap())
571                .map(|eid| {
572                    let (u, v) = b.edge(eid).unwrap();
573                    if u <= v { (u, v) } else { (v, u) }
574                })
575                .collect();
576            ea.sort_unstable();
577            eb.sort_unstable();
578            prop_assert_eq!(ea, eb);
579        }
580    }
581}