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}