Skip to main content

rust_igraph/algorithms/constructors/
square_lattice.rs

1//! Square (multi-dimensional grid) lattice constructor (ALGO-CN-009).
2//!
3//! Counterpart of `igraph_square_lattice()` in
4//! `references/igraph/src/constructors/regular.c:337-459`.
5//!
6//! Builds a `d`-dimensional square lattice with arbitrary per-dimension
7//! cardinalities and optional per-dimension torus wrap-around. The
8//! vertex at lattice position `(i_0, i_1, …, i_{d-1})` in a lattice of
9//! shape `(n_0, n_1, …, n_{d-1})` carries vertex id
10//! `i_0 + n_0 · i_1 + n_0·n_1 · i_2 + …` (little-endian, matching the
11//! upstream convention).
12//!
13//! Modes:
14//!
15//! * `directed = false`: each lattice edge is emitted once with the
16//!   lower vertex id as the source.
17//! * `directed = true, mutual = false`: each edge becomes a single
18//!   arc pointing from the lower-indexed vertex to the higher one.
19//! * `directed = true, mutual = true`: every undirected lattice edge
20//!   becomes a pair of opposite arcs. For periodic dimensions of size
21//!   `2` the upstream code suppresses the duplicate so the
22//!   forward/back wrap edges are not double-emitted; this port matches
23//!   that behaviour bit-for-bit.
24//!
25//! Limitations of this first cut:
26//!
27//! * `nei` (neighbour-distance radius) is restricted to `0` or `1`.
28//!   Higher values require the future `igraph_connect_neighborhood`
29//!   helper (a separate AWU) — they are rejected with
30//!   `InvalidArgument` for now.
31//!
32//! Special cases:
33//!
34//! * `dim = []` (zero dimensions) → singleton (one vertex).
35//! * any `dim[k] == 0` → null graph (zero vertices, zero edges).
36//! * any `dim[k] == 1` contributes no edges along that dimension.
37//!
38//! Algorithm: walk every vertex in little-endian lattice order. At
39//! each step, for every dimension `j`, emit the canonical
40//! "forward" edge to the neighbour with `coords[j] + 1` (or the
41//! wrap-around when `coords[j] == dim[j] − 1` and that dimension is
42//! periodic). When `directed && mutual`, additionally emit the
43//! "backward" reverse arc. Self-loops (only possible when a wrap
44//! produces the same vertex) and duplicate dim-2 edges are filtered
45//! out exactly as upstream does. Mirrors the C loop.
46//!
47//! Time complexity: `O(|V| · d) = O(|E|)`.
48
49use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
50
51/// Build a `d`-dimensional square lattice graph.
52///
53/// Returns a graph on `Π dim[k]` vertices arranged on a multi-dimensional
54/// grid, with edges joining vertices that are one step apart along any
55/// single axis. `periodic` (when supplied) selects which dimensions
56/// wrap around (torus mode).
57///
58/// # Parameters
59///
60/// * `dim` — per-dimension cardinalities. Empty slice is the
61///   zero-dimensional case (singleton).
62/// * `nei` — neighbour-distance radius. Only `0` and `1` are supported
63///   in this initial port (higher values require
64///   `igraph_connect_neighborhood`, a separate AWU).
65/// * `directed` — whether to produce a directed graph.
66/// * `mutual` — when `directed`, emit both directions of every edge.
67///   Ignored when `directed == false`.
68/// * `periodic` — optional per-dimension wrap-around flags. Must have
69///   the same length as `dim` when supplied; otherwise treat all
70///   dimensions as non-periodic.
71///
72/// # Errors
73///
74/// * [`IgraphError::InvalidArgument`] —
75///   * `nei >= 2` (not yet supported),
76///   * length of `periodic` does not match `dim`,
77///   * vertex count `Π dim[k]` overflows `u32`.
78///
79/// # Examples
80///
81/// ```
82/// use rust_igraph::square_lattice;
83///
84/// // 3 × 3 grid: 9 vertices, 12 edges, 2D bounded.
85/// let g = square_lattice(&[3, 3], 1, false, false, None).unwrap();
86/// assert_eq!(g.vcount(), 9);
87/// assert_eq!(g.ecount(), 12);
88///
89/// // 3 × 3 torus: same vertex count, 18 edges (each cell contributes
90/// // one forward edge per dim).
91/// let g = square_lattice(&[3, 3], 1, false, false, Some(&[true, true])).unwrap();
92/// assert_eq!(g.vcount(), 9);
93/// assert_eq!(g.ecount(), 18);
94/// ```
95pub fn square_lattice(
96    dim: &[u32],
97    nei: u32,
98    directed: bool,
99    mutual: bool,
100    periodic: Option<&[bool]>,
101) -> IgraphResult<Graph> {
102    if nei >= 2 {
103        return Err(IgraphError::InvalidArgument(format!(
104            "square_lattice: nei={nei} not yet supported (only nei in {{0, 1}}); \
105             higher radii require igraph_connect_neighborhood (future AWU)"
106        )));
107    }
108    if let Some(p) = periodic {
109        if p.len() != dim.len() {
110            return Err(IgraphError::InvalidArgument(format!(
111                "square_lattice: periodic vector length {} must match dim length {}",
112                p.len(),
113                dim.len()
114            )));
115        }
116    }
117
118    let dims = dim.len();
119
120    // vcount = Π dim[k]. Empty product is 1 (zero-dim → singleton).
121    let mut vcount: u32 = 1;
122    for &d in dim {
123        vcount = vcount.checked_mul(d).ok_or_else(|| {
124            IgraphError::InvalidArgument(format!(
125                "square_lattice: vertex count Π dim overflows u32 for dim={dim:?}"
126            ))
127        })?;
128    }
129
130    // Any zero in dim collapses everything to the null graph.
131    if vcount == 0 {
132        return Graph::new(0, directed);
133    }
134
135    // weights[i] = Π dim[0..i].
136    let mut weights: Vec<u32> = Vec::with_capacity(dims);
137    let mut acc: u32 = 1;
138    for &d in dim {
139        weights.push(acc);
140        acc = acc.checked_mul(d).ok_or_else(|| {
141            IgraphError::InvalidArgument(format!("square_lattice: weight overflow for dim={dim:?}"))
142        })?;
143    }
144
145    let mut coords: Vec<u32> = vec![0; dims];
146    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
147
148    let is_periodic = |idx: usize| -> bool { periodic.is_some_and(|p| p[idx]) };
149
150    for i in 0..vcount {
151        for j in 0..dims {
152            let dj = dim[j];
153            let pj = is_periodic(j);
154            let cj = coords[j];
155
156            // Forward neighbour: step +1 along axis j (or wrap when at boundary).
157            if pj || cj != dj - 1 {
158                let new_nei: u32 = if cj == dj - 1 {
159                    i - (dj - 1) * weights[j]
160                } else {
161                    i + weights[j]
162                };
163                // Skip self-loops (possible when wrap lands on the same id,
164                // e.g. dim[j] == 1) and duplicate dim-2 wrap edges in
165                // undirected mode.
166                if new_nei != i && (dj != 2 || cj != 1 || directed) {
167                    edges.push((i, new_nei));
168                }
169            }
170
171            // Backward neighbour, only when directed && mutual: emit the
172            // reverse arc so every undirected edge becomes a pair.
173            if mutual && directed && (pj || cj != 0) {
174                let new_nei: u32 = if cj != 0 {
175                    i - weights[j]
176                } else {
177                    i + (dj - 1) * weights[j]
178                };
179                // Skip self-loops and the dim-2 periodic case where
180                // forward and reverse wraps would point to the same edge.
181                if new_nei != i && (dj != 2 || !pj) {
182                    edges.push((i, new_nei));
183                }
184            }
185        }
186
187        // Little-endian carry: increment coords[0] first; on overflow
188        // reset and ripple to the next axis. After the last vertex this
189        // wraps back to all zeros, which is harmless since the outer
190        // loop terminates.
191        for pos in 0..dims {
192            if coords[pos] != dim[pos] - 1 {
193                coords[pos] += 1;
194                break;
195            }
196            coords[pos] = 0;
197        }
198    }
199
200    let mut g = Graph::new(vcount, directed)?;
201    g.add_edges(edges)?;
202    Ok(g)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
210        let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
211        (0..m)
212            .map(|e| g.edge(e).expect("edge id in bounds"))
213            .collect()
214    }
215
216    fn degree_of(g: &Graph, v: VertexId) -> usize {
217        g.degree(v).expect("vertex in range")
218    }
219
220    #[test]
221    fn zero_dim_is_singleton() {
222        let g = square_lattice(&[], 1, false, false, None).expect("0-dim");
223        assert_eq!(g.vcount(), 1);
224        assert_eq!(g.ecount(), 0);
225    }
226
227    #[test]
228    fn dim_one_is_singleton() {
229        let g = square_lattice(&[1], 1, false, false, None).expect("dim=[1]");
230        assert_eq!(g.vcount(), 1);
231        assert_eq!(g.ecount(), 0);
232    }
233
234    #[test]
235    fn zero_in_dim_collapses_to_null() {
236        let g = square_lattice(&[0, 3], 1, false, false, None).expect("null");
237        assert_eq!(g.vcount(), 0);
238        assert_eq!(g.ecount(), 0);
239    }
240
241    #[test]
242    fn one_d_path() {
243        // 1-D non-periodic dim=[3] → path P_3.
244        let g = square_lattice(&[3], 1, false, false, None).expect("P_3");
245        assert_eq!(g.vcount(), 3);
246        assert_eq!(g.ecount(), 2);
247        assert_eq!(dump_edges(&g), vec![(0, 1), (1, 2)]);
248    }
249
250    #[test]
251    fn one_d_periodic_is_cycle() {
252        // 1-D periodic dim=[3] → cycle C_3. Undirected wrap edge (2,0)
253        // gets normalized to (0,2) by Graph::add_edges.
254        let g = square_lattice(&[3], 1, false, false, Some(&[true])).expect("C_3");
255        assert_eq!(g.vcount(), 3);
256        assert_eq!(g.ecount(), 3);
257        assert_eq!(dump_edges(&g), vec![(0, 1), (1, 2), (0, 2)]);
258    }
259
260    #[test]
261    fn two_d_2x2_is_four_cycle() {
262        let g = square_lattice(&[2, 2], 1, false, false, None).expect("2x2");
263        assert_eq!(g.vcount(), 4);
264        assert_eq!(g.ecount(), 4);
265        assert_eq!(dump_edges(&g), vec![(0, 1), (0, 2), (1, 3), (2, 3)]);
266    }
267
268    #[test]
269    fn two_d_3x3_grid() {
270        // 3x3 grid non-periodic: 12 edges; vertex 4 (center) has degree 4.
271        let g = square_lattice(&[3, 3], 1, false, false, None).expect("3x3");
272        assert_eq!(g.vcount(), 9);
273        assert_eq!(g.ecount(), 12);
274        // Corner vertex 0 has degree 2; center 4 has degree 4; edge midpoint 1 has degree 3.
275        assert_eq!(degree_of(&g, 0), 2);
276        assert_eq!(degree_of(&g, 1), 3);
277        assert_eq!(degree_of(&g, 4), 4);
278        assert_eq!(degree_of(&g, 8), 2);
279    }
280
281    #[test]
282    fn two_d_3x3_torus() {
283        // 3x3 torus: each vertex has degree 4; ecount = 9*2 = 18.
284        let g = square_lattice(&[3, 3], 1, false, false, Some(&[true, true])).expect("torus");
285        assert_eq!(g.vcount(), 9);
286        assert_eq!(g.ecount(), 18);
287        for v in 0..g.vcount() {
288            assert_eq!(degree_of(&g, v), 4, "torus vertex {v} should be 4-regular");
289        }
290    }
291
292    #[test]
293    fn two_d_2x2_torus_does_not_double_edge() {
294        // For dim=2 periodic, wrap edges would duplicate — upstream
295        // suppresses them; the 2x2 torus is still just the 4-cycle.
296        let g = square_lattice(&[2, 2], 1, false, false, Some(&[true, true])).expect("torus 2x2");
297        assert_eq!(g.vcount(), 4);
298        assert_eq!(g.ecount(), 4);
299        assert_eq!(dump_edges(&g), vec![(0, 1), (0, 2), (1, 3), (2, 3)]);
300    }
301
302    #[test]
303    fn three_d_2x2x2_is_cube() {
304        // dim=[2,2,2] non-periodic = Q_3 (8v, 12e).
305        let g = square_lattice(&[2, 2, 2], 1, false, false, None).expect("Q_3");
306        assert_eq!(g.vcount(), 8);
307        assert_eq!(g.ecount(), 12);
308        for v in 0..g.vcount() {
309            assert_eq!(degree_of(&g, v), 3, "Q_3 vertex {v} should be 3-regular");
310        }
311    }
312
313    #[test]
314    fn directed_low_to_high_only() {
315        // Directed non-mutual path: each edge points from lower to higher.
316        let g = square_lattice(&[3], 1, true, false, None).expect("dir P_3");
317        assert!(g.is_directed());
318        assert_eq!(g.ecount(), 2);
319        assert_eq!(dump_edges(&g), vec![(0, 1), (1, 2)]);
320    }
321
322    #[test]
323    fn directed_mutual_emits_both_arcs() {
324        // dim=[3] directed mutual: arcs in both directions.
325        let g = square_lattice(&[3], 1, true, true, None).expect("dir mut P_3");
326        assert!(g.is_directed());
327        assert_eq!(g.ecount(), 4);
328        // Sorted by source-vertex order in the C loop: forward (0,1) and
329        // (1,2) from i=0 and i=1, and backward (1,0) and (2,1) from i=1
330        // and i=2 respectively.
331        let edges = dump_edges(&g);
332        assert!(edges.contains(&(0, 1)));
333        assert!(edges.contains(&(1, 0)));
334        assert!(edges.contains(&(1, 2)));
335        assert!(edges.contains(&(2, 1)));
336    }
337
338    #[test]
339    fn directed_mutual_periodic_3cycle() {
340        // dim=[3] periodic directed mutual: arcs both ways around C_3.
341        let g = square_lattice(&[3], 1, true, true, Some(&[true])).expect("dir mut C_3");
342        assert!(g.is_directed());
343        // Forward arcs (0,1),(1,2),(2,0) plus backward (1,0),(2,1),(0,2).
344        assert_eq!(g.ecount(), 6);
345    }
346
347    #[test]
348    fn mixed_periodicity() {
349        // dim=[3,3] with only first dim wrapping. Each row is a 3-cycle,
350        // each column is a path. Edge count: 3 cycles * 3 edges + 2 column
351        // edges * 3 cols = 9 + 6 = 15.
352        let g =
353            square_lattice(&[3, 3], 1, false, false, Some(&[true, false])).expect("mixed periodic");
354        assert_eq!(g.vcount(), 9);
355        assert_eq!(g.ecount(), 15);
356    }
357
358    #[test]
359    fn nei_two_or_more_rejected() {
360        let err = square_lattice(&[3, 3], 2, false, false, None).expect_err("nei=2");
361        match err {
362            IgraphError::InvalidArgument(msg) => assert!(msg.contains("nei")),
363            other => panic!("unexpected error: {other:?}"),
364        }
365    }
366
367    #[test]
368    fn periodic_length_mismatch_rejected() {
369        let err = square_lattice(&[3, 3], 1, false, false, Some(&[true])).expect_err("mismatch");
370        match err {
371            IgraphError::InvalidArgument(msg) => assert!(msg.contains("periodic")),
372            other => panic!("unexpected error: {other:?}"),
373        }
374    }
375
376    #[test]
377    fn vcount_overflow_rejected() {
378        // 65536 * 65536 = 2^32 → overflow.
379        let err = square_lattice(&[65536, 65536], 1, false, false, None).expect_err("overflow");
380        match err {
381            IgraphError::InvalidArgument(msg) => assert!(msg.contains("overflow")),
382            other => panic!("unexpected error: {other:?}"),
383        }
384    }
385}
386
387#[cfg(all(test, feature = "proptest-harness"))]
388mod proptests {
389    use super::*;
390    use proptest::prelude::*;
391
392    proptest! {
393        // For non-periodic 2-D grids, ecount = a*(b-1) + b*(a-1).
394        #[test]
395        fn two_d_grid_edge_count(a in 1u32..=6, b in 1u32..=6) {
396            let g = square_lattice(&[a, b], 1, false, false, None).unwrap();
397            let expected = a * (b - 1) + b * (a - 1);
398            prop_assert_eq!(g.vcount(), a * b);
399            prop_assert_eq!(g.ecount() as u32, expected);
400        }
401
402        // For fully-periodic 2-D torus with both dims >= 3, every vertex
403        // is 4-regular.
404        #[test]
405        fn two_d_torus_is_four_regular(a in 3u32..=6, b in 3u32..=6) {
406            let g = square_lattice(
407                &[a, b], 1, false, false, Some(&[true, true]),
408            ).unwrap();
409            for v in 0..g.vcount() {
410                prop_assert_eq!(g.degree(v).unwrap(), 4);
411            }
412        }
413
414        // dim=[2, 2, …] non-periodic with k dims is the hypercube Q_k.
415        #[test]
416        fn power_of_two_lattice_is_hypercube(k in 0u32..=5) {
417            use crate::hypercube;
418            let dims = vec![2u32; k as usize];
419            let g = square_lattice(&dims, 1, false, false, None).unwrap();
420            let q = hypercube(k, false).unwrap();
421            prop_assert_eq!(g.vcount(), q.vcount());
422            prop_assert_eq!(g.ecount(), q.ecount());
423        }
424
425        // Directed + mutual doubles the undirected edge count on a
426        // non-periodic 2-D grid.
427        #[test]
428        fn directed_mutual_doubles_undirected(a in 2u32..=5, b in 2u32..=5) {
429            let u = square_lattice(&[a, b], 1, false, false, None).unwrap();
430            let dm = square_lattice(&[a, b], 1, true, true, None).unwrap();
431            prop_assert_eq!(dm.ecount(), u.ecount() * 2);
432        }
433    }
434}