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}