Skip to main content

rust_igraph/algorithms/games/
grg.rs

1//! Geometric random graph (ALGO-GN-005).
2//!
3//! Counterpart of `igraph_grg_game()` in
4//! `references/igraph/src/games/grg.c:56-174`.
5//!
6//! ## Model
7//!
8//! Drop `n` points uniformly at random on the unit square
9//! `[0, 1) × [0, 1)`, then connect every pair `(i, j)` whose Euclidean
10//! distance is *strictly* less than `radius`. With `torus = true` the
11//! square is identified along opposite edges (periodic boundary
12//! conditions), so distances wrap modulo 1 along each axis.
13//!
14//! ## Algorithm — x-sweep with width-`radius` window
15//!
16//! 1. Sample `2n` independent `Uniform[0, 1)` values to populate `xs`
17//!    and `ys`. The `(x, y)` pairing is *not* preserved — upstream's
18//!    `igraph_vector_sort(xx)` mutates only the x array, leaving `ys`
19//!    in generation order. The resulting (x, y) joint marginal is still
20//!    uniform because the two axes were independent, but the original
21//!    point identities are lost. We mirror this behaviour exactly.
22//! 2. Sort `xs` in ascending order. For every `i` in `0..n` walk a
23//!    forward window `j = i + 1, i + 2, …` while `xs[j] - xs[i] <
24//!    radius`. Whenever the squared distance falls below `radius²`,
25//!    emit the edge `(i, j)`.
26//! 3. Torus mode adds a wrap-around tail: once the forward window
27//!    crosses `j = n`, continue at `j = 0, 1, …` for as long as the
28//!    wrapped x-gap `1 - xs[i] + xs[j]` is below `radius`. The y
29//!    coordinate is wrapped with `min(|Δy|, 1 - |Δy|)`.
30//!
31//! Both modes run in `O(n + |E|)` expected time once the sort cost
32//! `O(n log n)` is paid up front. The window invariant means each
33//! candidate pair is inspected exactly once, regardless of how dense
34//! the graph is.
35//!
36//! ## Determinism
37//!
38//! `grg_game` is fully deterministic in `(n, radius, torus, seed)`. The
39//! PRNG is `SplitMix64`; reusing the same seed in
40//! another generator (e.g. [`crate::tree_game_lerw`]) is safe — each
41//! call owns a fresh `SplitMix64` instance.
42//!
43//! ## Scope
44//!
45//! The upstream signature accepts optional output vectors for `x` and
46//! `y` coordinates. We provide both flavours:
47//! [`grg_game`] returns the graph only, and [`grg_game_with_coords`]
48//! returns the sorted `xs` and the original-order `ys`. Both call into
49//! the same internal worker so the edge set is byte-for-byte identical
50//! for the same seed.
51
52#![allow(
53    clippy::cast_possible_truncation,
54    clippy::cast_sign_loss,
55    clippy::cast_precision_loss
56)]
57
58use crate::core::rng::SplitMix64;
59use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
60
61fn validate_inputs(n: u32, radius: f64) -> IgraphResult<()> {
62    if !radius.is_finite() {
63        return Err(IgraphError::Internal("grg_game radius must be finite"));
64    }
65    // Hard cap on potential edges. n choose 2 stays within u64 across
66    // u32::MAX safely (2^63 ≫ 2^31 · (2^31 - 1) / 2 ≈ 2^61). We don't
67    // pre-allocate the edge list because the actual edge count depends
68    // on radius — capacity grows dynamically. The validation here is
69    // purely a "non-NaN, non-infinite radius" gate so the sweep
70    // comparison `dx < radius` is well-defined.
71    let _ = n;
72    Ok(())
73}
74
75/// Generate a geometric random graph on `n` uniformly placed points in
76/// the unit square, connecting pairs strictly closer than `radius` in
77/// Euclidean distance.
78///
79/// * `n` — vertex count. `n = 0` returns an empty undirected graph;
80///   `n = 1` returns a single isolated vertex.
81/// * `radius` — Euclidean cut-off in the same units as the unit square.
82///   Negative values are coerced to `0` (matching upstream). The
83///   maximum meaningful value is `sqrt(2)` for the open square or
84///   `sqrt(0.5)` for the torus (above this every pair is in range).
85/// * `torus` — periodic boundary conditions when `true`. The unit
86///   square is identified with a torus and y-distances wrap by
87///   `1 - |Δy|` if shorter; x-wrapping is folded into the sweep tail.
88/// * `seed` — seeds the internal `SplitMix64` PRNG. Same
89///   `(n, radius, torus, seed)` always yields the same graph.
90///
91/// The generated graph is always undirected and free of self-loops
92/// (the sweep starts at `j = i + 1`). It is also simple — each pair
93/// `(i, j)` is inspected at most once, so no multi-edges arise.
94///
95/// # Errors
96///
97/// Returns [`IgraphError::Internal`] if `radius` is `NaN` or
98/// infinite.
99///
100/// # Examples
101///
102/// ```
103/// use rust_igraph::grg_game;
104///
105/// // Dense regime: radius near sqrt(2) connects almost every pair.
106/// let dense = grg_game(20, 1.5, false, 0xA110).unwrap();
107/// assert_eq!(dense.vcount(), 20);
108/// assert!(dense.ecount() > 100);
109/// assert!(!dense.is_directed());
110/// ```
111pub fn grg_game(n: u32, radius: f64, torus: bool, seed: u64) -> IgraphResult<Graph> {
112    let (graph, _xs, _ys) = grg_game_with_coords(n, radius, torus, seed)?;
113    Ok(graph)
114}
115
116/// Same as [`grg_game`] but also returns the point coordinates.
117///
118/// The first vector is `xs` in ascending order — `xs[i]` is the
119/// x-coordinate of vertex `i`. The second vector is `ys` in *generation
120/// order* (i.e. not paired with `xs[i]`), mirroring upstream
121/// `igraph_grg_game(..., x, y)` which sorts only the x array. Callers
122/// who need the original (x, y) pairing should generate the points
123/// themselves and call [`grg_game`] separately.
124///
125/// # Errors
126///
127/// Same as [`grg_game`].
128///
129/// # Examples
130///
131/// ```
132/// use rust_igraph::grg_game_with_coords;
133///
134/// let (g, xs, ys) = grg_game_with_coords(20, 0.4, false, 42).unwrap();
135/// assert_eq!(g.vcount(), 20);
136/// assert_eq!(xs.len(), 20);
137/// assert_eq!(ys.len(), 20);
138/// ```
139pub fn grg_game_with_coords(
140    n: u32,
141    radius: f64,
142    torus: bool,
143    seed: u64,
144) -> IgraphResult<(Graph, Vec<f64>, Vec<f64>)> {
145    validate_inputs(n, radius)?;
146    let n_usize = n as usize;
147    let radius = radius.max(0.0);
148    let r2 = radius * radius;
149
150    let mut xs: Vec<f64> = Vec::with_capacity(n_usize);
151    let mut ys: Vec<f64> = Vec::with_capacity(n_usize);
152    let mut rng = SplitMix64::new(seed);
153    for _ in 0..n_usize {
154        xs.push(rng.gen_unit());
155        ys.push(rng.gen_unit());
156    }
157    xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
158
159    // Trivial / degenerate cases: never push any edges. `radius == 0`
160    // mirrors upstream's strict `<` comparison — a pair must be
161    // *strictly* closer than radius to connect, so zero-radius means
162    // no edges at all even if two points coincide.
163    if n_usize < 2 || radius <= 0.0 {
164        let g = Graph::new(n, false)?;
165        return Ok((g, xs, ys));
166    }
167
168    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
169
170    if torus {
171        for i in 0..n_usize {
172            let xi = xs[i];
173            let yi = ys[i];
174            // Forward window — no wrap yet.
175            let mut j = i + 1;
176            while j < n_usize {
177                let dx = xs[j] - xi;
178                if dx >= radius {
179                    break;
180                }
181                let mut dy = (ys[j] - yi).abs();
182                if dy > 0.5 {
183                    dy = 1.0 - dy;
184                }
185                if dx * dx + dy * dy < r2 {
186                    edges.push((i as VertexId, j as VertexId));
187                }
188                j += 1;
189            }
190            // Wrap-around tail: only enters when the forward window
191            // exhausted the rest of `xs`. Upstream `grg.c:142-156`
192            // gates on `j == nodes`, which is true here once `while
193            // j < n_usize` exits — by either `j == n` or
194            // `xs[j] - xi >= radius`. The second case implies no
195            // point at `j == n - k` could close the wrap window
196            // anyway, so the `j == n` gate keeps the inner loop tight.
197            if j == n_usize {
198                let mut k = 0usize;
199                while k < i {
200                    // Wrapped x-distance: (1 - xi) + xs[k]
201                    let dx = 1.0 - xi + xs[k];
202                    if dx >= radius {
203                        break;
204                    }
205                    // Upstream also enforces the forward gap exceeds
206                    // radius to avoid double-counting the same pair —
207                    // (xi - xs[k]) > radius means k is far enough to
208                    // the *left* that the forward window from k would
209                    // not have reached i in the first place.
210                    if xi - xs[k] < radius {
211                        k += 1;
212                        continue;
213                    }
214                    let mut dy = (ys[k] - yi).abs();
215                    if dy > 0.5 {
216                        dy = 1.0 - dy;
217                    }
218                    if dx * dx + dy * dy < r2 {
219                        // Emit (k, i) rather than (i, k) so the edge
220                        // list stays sorted by lower endpoint, matching
221                        // the forward-window order above.
222                        edges.push((k as VertexId, i as VertexId));
223                    }
224                    k += 1;
225                }
226            }
227        }
228    } else {
229        for i in 0..n_usize {
230            let xi = xs[i];
231            let yi = ys[i];
232            let mut j = i + 1;
233            while j < n_usize {
234                let dx = xs[j] - xi;
235                if dx >= radius {
236                    break;
237                }
238                let dy = ys[j] - yi;
239                if dx * dx + dy * dy < r2 {
240                    edges.push((i as VertexId, j as VertexId));
241                }
242                j += 1;
243            }
244        }
245    }
246
247    let mut g = Graph::new(n, false)?;
248    g.add_edges(edges)?;
249    Ok((g, xs, ys))
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::collections::HashSet;
256
257    fn edge_set(g: &Graph) -> HashSet<(VertexId, VertexId)> {
258        let n_edges = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
259        (0..n_edges)
260            .map(|eid| {
261                let (a, b) = g.edge(eid).expect("edge id in bounds");
262                if a <= b { (a, b) } else { (b, a) }
263            })
264            .collect()
265    }
266
267    #[test]
268    fn n_zero_returns_empty_graph() {
269        let g = grg_game(0, 0.1, false, 1).unwrap();
270        assert_eq!(g.vcount(), 0);
271        assert_eq!(g.ecount(), 0);
272    }
273
274    #[test]
275    fn n_one_returns_singleton() {
276        let g = grg_game(1, 0.5, false, 1).unwrap();
277        assert_eq!(g.vcount(), 1);
278        assert_eq!(g.ecount(), 0);
279    }
280
281    #[test]
282    fn zero_radius_yields_empty_graph() {
283        // Strict inequality dx² + dy² < 0 is never satisfied.
284        let g = grg_game(50, 0.0, false, 0xDEAD).unwrap();
285        assert_eq!(g.vcount(), 50);
286        assert_eq!(g.ecount(), 0);
287    }
288
289    #[test]
290    fn negative_radius_treated_as_zero() {
291        let g = grg_game(50, -1.0, false, 0xDEAD).unwrap();
292        assert_eq!(g.ecount(), 0);
293    }
294
295    #[test]
296    fn always_undirected() {
297        let g = grg_game(10, 0.5, false, 1).unwrap();
298        assert!(!g.is_directed());
299        let g = grg_game(10, 0.5, true, 1).unwrap();
300        assert!(!g.is_directed());
301    }
302
303    #[test]
304    fn no_self_loops_no_multi_edges() {
305        for &(n, r, torus) in &[
306            (50u32, 0.2, false),
307            (50, 0.2, true),
308            (200, 0.05, false),
309            (200, 0.05, true),
310        ] {
311            let g = grg_game(n, r, torus, 0x5EED).unwrap();
312            let edges = edge_set(&g);
313            assert_eq!(edges.len(), g.ecount(), "multi-edges in {n} {r} {torus}");
314            for &(a, b) in &edges {
315                assert_ne!(a, b, "self-loop in {n} {r} {torus}");
316            }
317        }
318    }
319
320    #[test]
321    fn dense_radius_connects_almost_everything_plane() {
322        // sqrt(2) > 1.41 covers the entire unit square: every pair is
323        // strictly within range. Expected edges = n(n-1)/2.
324        let n = 30u32;
325        let g = grg_game(n, 2.0, false, 0xBEEF).unwrap();
326        let expected = (n as usize) * (n as usize - 1) / 2;
327        assert_eq!(g.ecount(), expected);
328    }
329
330    #[test]
331    fn dense_radius_connects_almost_everything_torus() {
332        // sqrt(0.5) ≈ 0.707 is enough on the torus to reach every other
333        // point — but our sweep tail only enters when xi - xs[k] >=
334        // radius, so at radius > 1 the tail is entirely empty and we
335        // get a *forward-only* complete graph from the main sweep
336        // anyway. The expected count remains n(n-1)/2.
337        let n = 30u32;
338        let g = grg_game(n, 2.0, true, 0xBEEF).unwrap();
339        let expected = (n as usize) * (n as usize - 1) / 2;
340        assert_eq!(g.ecount(), expected);
341    }
342
343    #[test]
344    fn determinism_same_seed_same_graph() {
345        let a = grg_game(100, 0.15, false, 0x1234).unwrap();
346        let b = grg_game(100, 0.15, false, 0x1234).unwrap();
347        assert_eq!(a.vcount(), b.vcount());
348        assert_eq!(a.ecount(), b.ecount());
349        assert_eq!(edge_set(&a), edge_set(&b));
350    }
351
352    #[test]
353    fn distinct_seeds_yield_distinct_graphs() {
354        let a = grg_game(200, 0.1, false, 0x1).unwrap();
355        let b = grg_game(200, 0.1, false, 0x2).unwrap();
356        assert_ne!(edge_set(&a), edge_set(&b));
357    }
358
359    #[test]
360    fn torus_adds_edges_versus_plane() {
361        // Periodic boundaries can only connect *additional* pairs that
362        // wrap across the unit square — never disconnect existing ones.
363        // Run several seeds because the expected delta is positive only
364        // in expectation; we verify monotonicity (torus >= plane) on
365        // every seed.
366        for seed in 0u64..16 {
367            let plane = grg_game(80, 0.12, false, seed).unwrap();
368            let torus = grg_game(80, 0.12, true, seed).unwrap();
369            assert!(
370                torus.ecount() >= plane.ecount(),
371                "seed {seed}: torus={} plane={}",
372                torus.ecount(),
373                plane.ecount(),
374            );
375        }
376    }
377
378    #[test]
379    fn coords_returned_match_graph() {
380        let (g, xs, ys) = grg_game_with_coords(64, 0.1, false, 0x77).unwrap();
381        assert_eq!(xs.len(), 64);
382        assert_eq!(ys.len(), 64);
383        // xs is sorted; ys is not.
384        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
385        // Same seed via grg_game must produce the same edge set.
386        let g2 = grg_game(64, 0.1, false, 0x77).unwrap();
387        assert_eq!(edge_set(&g), edge_set(&g2));
388    }
389
390    #[test]
391    fn nan_radius_errors() {
392        assert!(grg_game(10, f64::NAN, false, 1).is_err());
393    }
394
395    #[test]
396    fn inf_radius_errors() {
397        assert!(grg_game(10, f64::INFINITY, false, 1).is_err());
398    }
399
400    #[test]
401    fn expected_density_in_band() {
402        // E[edges] = n·(n-1)/2 · π·r² for the plane interior — boundary
403        // effects pull the actual mean down by a small constant. We use
404        // a loose window centred on the bulk estimate so the test is
405        // robust to RNG variance: ± 50 % of the predicted count.
406        let n = 400u32;
407        let r = 0.05;
408        let predicted = (f64::from(n) * f64::from(n - 1) / 2.0) * std::f64::consts::PI * r * r;
409        let g = grg_game(n, r, true, 0xBEEF).unwrap();
410        let actual = g.ecount() as f64;
411        assert!(
412            actual > predicted * 0.5 && actual < predicted * 1.5,
413            "expected ~{predicted:.1} edges, got {actual}",
414        );
415    }
416}