Skip to main content

rust_igraph/algorithms/constructors/
hexagonal_lattice.rs

1//! Hexagonal lattice constructor (ALGO-CN-024).
2//!
3//! Counterpart of `igraph_hexagonal_lattice()` in
4//! `references/igraph/src/constructors/lattices.c:572-616`.
5//!
6//! Builds a planar hexagonal (honeycomb) lattice whose vertices have
7//! coordinates `(i, j)` for non-negative integers, with `(i, j)` joined
8//! to `(i + 1, j)` and (when `i` is odd) also to `(i - 1, j + 1)`. The
9//! graph is the planar dual of [`crate::triangular_lattice`]:
10//! 1:1 correspondence between length-6 cycles here and triangles there.
11//! Every vertex has degree at most 3.
12//!
13//! The `dims` slice doubles as a *shape* selector with three modes:
14//!
15//! * `[n]`                  — triangular outline, `n` hexagons per side
16//! * `[size_x, size_y]`     — quasi-rectangle, `size_x × size_y` hexagons
17//! * `[size_x, size_y, size_z]` — hexagonal outline with the three sides
18//!   carrying `size_x`, `size_y`, `size_z` hexagons respectively
19//!
20//! Vertices are numbered lexicographically with the second coordinate
21//! more significant, matching the upstream `lex_ordering = false` path.
22//!
23//! Modes:
24//!
25//! * `directed = false`: every undirected lattice edge is emitted once.
26//! * `directed = true, mutual = false`: each lattice edge becomes a
27//!   single arc from the lower id to the higher.
28//! * `directed = true, mutual = true`: every undirected lattice edge
29//!   becomes a pair of opposite arcs.
30//!
31//! Special cases:
32//!
33//! * Any `dims[k] == 0` → empty graph (upstream
34//!   `igraph_vector_int_any_smaller(dims, 1)` guard).
35//! * `dims.len() != 1, 2, 3` → [`IgraphError::InvalidArgument`].
36//!
37//! Algorithm: byte-for-byte port of upstream's three private shape
38//! helpers (`hexagonal_lattice_triangle_shape` /
39//! `hexagonal_lattice_rectangle_shape` / `hexagonal_lattice_hex_shape`)
40//! followed by the shared `hexagonal_lattice` emitter, which walks
41//! every site and emits the two candidate forward neighbours (right,
42//! and — only for odd `k` — up-left).
43//!
44//! Time complexity: `O(|V| + |E|)`.
45
46use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
47
48/// Build a hexagonal (honeycomb) lattice with the requested shape.
49///
50/// See the module documentation for the meaning of `dims`,
51/// `directed`, and `mutual`.
52///
53/// # Errors
54///
55/// * [`IgraphError::InvalidArgument`] — when:
56///   * `dims.len()` is not in `{1, 2, 3}` (upstream
57///     `IGRAPH_EINVAL: "size of the dimension vector must be 1, 2 or 3"`),
58///   * the implied vertex or edge count overflows `u32`.
59///
60/// The upstream "negative dimension" path is eliminated at the type
61/// level by taking `&[u32]`.
62///
63/// # Examples
64///
65/// ```
66/// use rust_igraph::hexagonal_lattice;
67///
68/// // dims=[1] — a single hexagon: 6 vertices forming C_6 with 6 edges.
69/// let g = hexagonal_lattice(&[1], false, false).unwrap();
70/// assert_eq!(g.vcount(), 6);
71/// assert_eq!(g.ecount(), 6);
72///
73/// // 2 x 2 quasi-rectangle from python-igraph testHexagonalLattice.
74/// let g = hexagonal_lattice(&[2, 2], false, false).unwrap();
75/// assert_eq!(g.vcount(), 16);
76/// assert_eq!(g.ecount(), 19);
77///
78/// // Zero in any dim collapses to the empty graph.
79/// let g = hexagonal_lattice(&[3, 0], false, false).unwrap();
80/// assert_eq!(g.vcount(), 0);
81/// ```
82pub fn hexagonal_lattice(dims: &[u32], directed: bool, mutual: bool) -> IgraphResult<Graph> {
83    if !matches!(dims.len(), 1..=3) {
84        return Err(IgraphError::InvalidArgument(format!(
85            "hexagonal_lattice: size of dimension vector must be 1, 2 or 3, got {}",
86            dims.len()
87        )));
88    }
89
90    // Upstream short-circuit: any zero dim → empty graph.
91    if dims.contains(&0) {
92        return Graph::new(0, directed);
93    }
94
95    let (row_lengths, row_start) = match dims.len() {
96        1 => triangle_shape(dims[0]),
97        2 => rectangle_shape(dims[0], dims[1]),
98        3 => hex_shape(dims[0], dims[1], dims[2])?,
99        _ => unreachable!("dims length already checked"),
100    };
101
102    layout(&row_lengths, &row_start, directed, mutual)
103}
104
105/// Triangular-outline shape (`dims = [size]`).
106///
107/// Upstream sets `row_count = size + 2` and iterates `i = 0..row_count - 1`
108/// (so `size + 1` rows). For `i = 0` the row has `2*row_count - 3`
109/// vertices and starts at column `1`; for `i >= 1` the row has
110/// `2*(row_count - i) - 1` vertices and starts at column `0`.
111fn triangle_shape(size: u32) -> (Vec<u32>, Vec<u32>) {
112    let row_count_raw = size + 2;
113    let n_rows = (size + 1) as usize;
114    let mut row_lengths = Vec::with_capacity(n_rows);
115    let mut row_start = Vec::with_capacity(n_rows);
116    for i in 0..(row_count_raw - 1) {
117        let len = 2 * (row_count_raw - i) - if i == 0 { 3 } else { 1 };
118        row_lengths.push(len);
119        row_start.push(u32::from(i == 0));
120    }
121    (row_lengths, row_start)
122}
123
124/// Quasi-rectangular shape (`dims = [size_x, size_y]`).
125///
126/// `row_count = size_x + 1`. `actual_size_y = 2*(size_y + 1)`. The
127/// first and last rows shed one vertex; the others carry
128/// `actual_size_y`. `row_start[i] = row_count - i - 1`, with an extra
129/// `+1` in the first row when the residual `(row_count - i - 1) % 2`
130/// is even, mirroring upstream's `is_first_row && !is_start_odd`.
131fn rectangle_shape(size_x: u32, size_y: u32) -> (Vec<u32>, Vec<u32>) {
132    let row_count = size_x + 1;
133    let n_rows = row_count as usize;
134    let actual_size_y = (size_y + 1) * 2;
135
136    let mut row_lengths = Vec::with_capacity(n_rows);
137    let mut row_start = Vec::with_capacity(n_rows);
138
139    for i in 0..row_count {
140        let is_first_row = i == 0;
141        let is_last_row = i == row_count - 1;
142        let is_start_odd = ((row_count - i - 1) % 2) != 0;
143        let len = actual_size_y - u32::from(is_first_row || is_last_row);
144        let start = (row_count - i - 1) + u32::from(is_first_row && !is_start_odd);
145        row_lengths.push(len);
146        row_start.push(start);
147    }
148    (row_lengths, row_start)
149}
150
151/// Hexagonal-outline shape (`dims = [size_x, size_y, size_z]`).
152///
153/// `row_count = size_y + size_z`. Initial `row_length = 2*size_x + 1`,
154/// initial `row_start = 2*size_y - 1`. Three-phase update with
155/// thresholds `first = min(size_y, size_z) - 1`,
156/// `second = max(size_y, size_z) - 1`, and `sgn = if size_y < size_z
157/// { 0 } else { -2 }` (encoded as a boolean for the middle phase).
158/// Two extra corrections at `i == size_y - 1` and `i == size_z - 1`
159/// match upstream byte-for-byte.
160fn hex_shape(size_x: u32, size_y: u32, size_z: u32) -> IgraphResult<(Vec<u32>, Vec<u32>)> {
161    let row_count = size_y + size_z;
162    let n_rows = row_count as usize;
163
164    // Use i64 internally because the middle phase can transiently
165    // decrement `row_start` (sgn_flag = -2) before later phases bring
166    // it back. The final values written to the vec are non-negative by
167    // construction (upstream invariant) but we widen to be defensive.
168    let mut row_length: i64 = i64::from(size_x) * 2 + 1;
169    let mut row_start_val: i64 = i64::from(size_y) * 2 - 1;
170    let first_threshold: i64 = i64::from(size_y.min(size_z)) - 1;
171    let second_threshold: i64 = i64::from(size_y.max(size_z)) - 1;
172    let middle_shrinks: bool = size_y >= size_z;
173
174    let mut row_lengths = Vec::with_capacity(n_rows);
175    let mut row_start = Vec::with_capacity(n_rows);
176
177    for i in 0..row_count {
178        let len_u32 = u32::try_from(row_length).map_err(|_| {
179            crate::core::IgraphError::InvalidArgument("row_length out of u32 range".into())
180        })?;
181        let start_u32 = u32::try_from(row_start_val).map_err(|_| {
182            crate::core::IgraphError::InvalidArgument("row_start out of u32 range".into())
183        })?;
184        row_lengths.push(len_u32);
185        row_start.push(start_u32);
186
187        let ii = i64::from(i);
188        if ii < first_threshold {
189            row_length += 2;
190            row_start_val -= 2;
191        } else if ii < second_threshold {
192            if middle_shrinks {
193                row_start_val -= 2;
194            }
195        } else {
196            row_length -= 2;
197        }
198        if i == size_y - 1 {
199            row_start_val -= 1;
200            row_length += 1;
201        }
202        if i == size_z - 1 {
203            row_length += 1;
204        }
205    }
206
207    Ok((row_lengths, row_start))
208}
209
210/// Emit edges from per-row metadata and assemble the graph.
211///
212/// Per upstream: for every vertex `(i, j)` emit:
213/// * the right neighbour `(k + 1, j)` when it sits inside row `j`'s
214///   span,
215/// * the up-left neighbour `(k - 1, j + 1)` *only* when `k` is odd and
216///   row `j + 1` exists,
217///
218/// where `k = row_start[j] + i`. Vertex id is
219/// `prefix_sum[j] + (i - row_start[j])` (= `prefix_sum[j] + i_local`).
220fn layout(
221    row_lengths: &[u32],
222    row_start: &[u32],
223    directed: bool,
224    mutual: bool,
225) -> IgraphResult<Graph> {
226    debug_assert_eq!(row_lengths.len(), row_start.len());
227    let row_count = row_lengths.len();
228
229    let mut prefix_sum: Vec<u32> = Vec::with_capacity(row_count + 1);
230    prefix_sum.push(0);
231    for &len in row_lengths {
232        let Some(&last) = prefix_sum.last() else {
233            return Err(overflow_error("empty prefix sum"));
234        };
235        let next = last
236            .checked_add(len)
237            .ok_or_else(|| overflow_error("vertex count"))?;
238        prefix_sum.push(next);
239    }
240    let Some(&vcount) = prefix_sum.last() else {
241        return Err(overflow_error("empty prefix sum"));
242    };
243
244    let vertex_index = |i: u32, j: usize| -> u32 { prefix_sum[j] + i - row_start[j] };
245    let row_end = |j: usize| -> u32 { row_start[j] + row_lengths[j] - 1 };
246
247    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
248
249    let add_if_in_bounds =
250        |edges: &mut Vec<(VertexId, VertexId)>, i: u32, j: usize, k_opt: Option<u32>, l: usize| {
251            let Some(k) = k_opt else {
252                return;
253            };
254            if l >= row_count {
255                return;
256            }
257            let l_start = row_start[l];
258            let l_end = row_end(l);
259            if k < l_start || k > l_end {
260                return;
261            }
262            let src = vertex_index(i, j);
263            let dst = vertex_index(k, l);
264            edges.push((src, dst));
265            if directed && mutual {
266                edges.push((dst, src));
267            }
268        };
269
270    for j in 0..row_count {
271        let row_len = row_lengths[j];
272        let start = row_start[j];
273        for i in 0..row_len {
274            let k = start + i;
275            // Right neighbour: always tried, succeeds iff (k+1) sits
276            // inside row j's span (so the rightmost vertex of each row
277            // never emits anything to the right).
278            add_if_in_bounds(&mut edges, k, j, Some(k + 1), j);
279            // Up-left neighbour: only for odd k and only when row j+1
280            // exists. Skips when k == 0 (no `k - 1` available).
281            if j + 1 < row_count && k % 2 == 1 {
282                add_if_in_bounds(&mut edges, k, j, k.checked_sub(1), j + 1);
283            }
284        }
285    }
286
287    let mut g = Graph::new(vcount, directed)?;
288    g.add_edges(edges)?;
289    Ok(g)
290}
291
292fn overflow_error(kind: &str) -> IgraphError {
293    IgraphError::InvalidArgument(format!("hexagonal_lattice: {kind} overflows u32"))
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::collections::BTreeSet;
300
301    fn canonical_undirected(g: &Graph) -> BTreeSet<(u32, u32)> {
302        let mut s = BTreeSet::new();
303        for v in 0..g.vcount() {
304            for &u in &g.neighbors(v).expect("neighbors") {
305                let key = if v <= u { (v, u) } else { (u, v) };
306                s.insert(key);
307            }
308        }
309        s
310    }
311
312    fn directed_arcs(g: &Graph) -> BTreeSet<(u32, u32)> {
313        (0..u32::try_from(g.ecount()).expect("ecount fits u32"))
314            .map(|eid| g.edge(eid).expect("edge"))
315            .collect()
316    }
317
318    #[test]
319    fn empty_dims_rejected() {
320        let r = hexagonal_lattice(&[], false, false);
321        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
322    }
323
324    #[test]
325    fn four_dim_rejected() {
326        let r = hexagonal_lattice(&[1, 2, 3, 4], false, false);
327        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
328    }
329
330    #[test]
331    fn zero_dim_yields_empty_graph() {
332        let g = hexagonal_lattice(&[3, 0], false, false).expect("ok");
333        assert_eq!(g.vcount(), 0);
334        assert_eq!(g.ecount(), 0);
335    }
336
337    #[test]
338    fn zero_dim_directed_keeps_flag() {
339        let g = hexagonal_lattice(&[0, 3, 4], true, true).expect("ok");
340        assert_eq!(g.vcount(), 0);
341        assert!(g.is_directed());
342    }
343
344    #[test]
345    fn single_hexagon_matches_upstream_c6() {
346        // References .out: dims=[1] directed=true gives a 6-vertex C_6
347        // with 6 directed edges (canonical hexagon).
348        let g = hexagonal_lattice(&[1], true, false).expect("ok");
349        assert_eq!(g.vcount(), 6);
350        assert_eq!(g.ecount(), 6);
351        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 3), (1, 2), (2, 5), (3, 4), (4, 5)]
352            .into_iter()
353            .collect();
354        assert_eq!(directed_arcs(&g), want);
355    }
356
357    #[test]
358    fn triangular_hex_lattice_side_5_matches_upstream_vcount() {
359        // References .out: dims=[5] directed=true → vcount=46, 60 arcs.
360        let g = hexagonal_lattice(&[5], true, false).expect("ok");
361        assert_eq!(g.vcount(), 46);
362        assert_eq!(g.ecount(), 60);
363    }
364
365    #[test]
366    fn rectangle_4x5_directed_mutual_matches_upstream_vcount() {
367        // References .out: dims=[4, 5] directed=true mutual=true.
368        // The "edges:" block spans 154 lines → 77 undirected edges × 2.
369        let g = hexagonal_lattice(&[4, 5], true, true).expect("ok");
370        assert_eq!(g.vcount(), 58);
371        assert_eq!(g.ecount(), 154);
372    }
373
374    #[test]
375    fn rectangle_2x2_matches_python_igraph_undirected() {
376        // python-igraph testHexagonalLattice: Graph.Hexagonal_Lattice([2, 2])
377        // sorted edge list has 19 edges.
378        let g = hexagonal_lattice(&[2, 2], false, false).expect("ok");
379        assert_eq!(g.vcount(), 16);
380        assert_eq!(g.ecount(), 19);
381        let want: BTreeSet<(u32, u32)> = [
382            (0, 1),
383            (0, 6),
384            (1, 2),
385            (2, 3),
386            (2, 8),
387            (3, 4),
388            (4, 10),
389            (5, 6),
390            (5, 11),
391            (6, 7),
392            (7, 8),
393            (7, 13),
394            (8, 9),
395            (9, 10),
396            (9, 15),
397            (11, 12),
398            (12, 13),
399            (13, 14),
400            (14, 15),
401        ]
402        .into_iter()
403        .collect();
404        assert_eq!(canonical_undirected(&g), want);
405    }
406
407    #[test]
408    fn rectangle_2x2_directed_unilateral_matches_undirected_edges() {
409        let g = hexagonal_lattice(&[2, 2], true, false).expect("ok");
410        let want: BTreeSet<(u32, u32)> = [
411            (0, 1),
412            (0, 6),
413            (1, 2),
414            (2, 3),
415            (2, 8),
416            (3, 4),
417            (4, 10),
418            (5, 6),
419            (5, 11),
420            (6, 7),
421            (7, 8),
422            (7, 13),
423            (8, 9),
424            (9, 10),
425            (9, 15),
426            (11, 12),
427            (12, 13),
428            (13, 14),
429            (14, 15),
430        ]
431        .into_iter()
432        .collect();
433        assert_eq!(directed_arcs(&g), want);
434    }
435
436    #[test]
437    fn rectangle_2x2_directed_mutual_doubles_edges() {
438        let g = hexagonal_lattice(&[2, 2], true, true).expect("ok");
439        assert_eq!(g.ecount(), 19 * 2);
440        assert!(g.is_directed());
441    }
442
443    #[test]
444    fn hexagonal_3_4_5_matches_upstream_vcount() {
445        // References .out: dims=[3, 4, 5] directed=false mutual=true →
446        // vcount=94 with 129 undirected edges (directed=false silently
447        // collapses mutual; the .out edge block is 129 lines).
448        let g = hexagonal_lattice(&[3, 4, 5], false, true).expect("ok");
449        assert_eq!(g.vcount(), 94);
450        assert_eq!(g.ecount(), 129);
451        // Every vertex degree ≤ 3 (hexagonal lattice invariant).
452        for v in 0..g.vcount() {
453            assert!(g.degree(v).expect("deg") <= 3);
454        }
455    }
456
457    #[test]
458    fn all_vertices_have_degree_at_most_three() {
459        for &(sx, sy, sz) in &[(3u32, 4u32, 5u32), (2, 3, 4), (5, 5, 5)] {
460            let g = hexagonal_lattice(&[sx, sy, sz], false, false).expect("ok");
461            for v in 0..g.vcount() {
462                assert!(g.degree(v).expect("deg") <= 3);
463            }
464        }
465    }
466}
467
468#[cfg(all(test, feature = "proptest-harness"))]
469mod proptests {
470    use super::*;
471    use proptest::prelude::*;
472
473    proptest! {
474        #[test]
475        fn directed_mutual_doubles_undirected_ecount(
476            sx in 1u32..6,
477            sy in 1u32..6,
478        ) {
479            let undirected = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
480            let mutual = hexagonal_lattice(&[sx, sy], true, true).expect("ok");
481            prop_assert_eq!(mutual.ecount(), undirected.ecount() * 2);
482        }
483
484        #[test]
485        fn directed_unilateral_matches_undirected_ecount(
486            sx in 1u32..6,
487            sy in 1u32..6,
488        ) {
489            let undirected = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
490            let unilateral = hexagonal_lattice(&[sx, sy], true, false).expect("ok");
491            prop_assert_eq!(unilateral.ecount(), undirected.ecount());
492        }
493
494        #[test]
495        fn directedness_flag_round_trips(
496            sx in 1u32..6,
497            sy in 1u32..6,
498            directed: bool,
499            mutual: bool,
500        ) {
501            let g = hexagonal_lattice(&[sx, sy], directed, mutual).expect("ok");
502            prop_assert_eq!(g.is_directed(), directed);
503        }
504
505        #[test]
506        fn max_degree_at_most_three(
507            sx in 1u32..6,
508            sy in 1u32..6,
509        ) {
510            let g = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
511            for v in 0..g.vcount() {
512                prop_assert!(g.degree(v).unwrap() <= 3);
513            }
514        }
515
516        #[test]
517        fn triangle_shape_vcount_grows_quadratically(
518            n in 1u32..8,
519        ) {
520            // Triangular outline vcount is the sum of the per-row lengths.
521            // Empirically: side 1 → 6, side 2 → 13, side 3 → 22,
522            // side 4 → 33, side 5 → 46 (matches upstream .out).
523            let g = hexagonal_lattice(&[n], false, false).expect("ok");
524            // Closed form: n*(2n+5) + (2n+1)+(2n-1) = n^2 + (n+1)*(2n+3)
525            // − but rather than fight the formula, assert lower/upper bound.
526            prop_assert!(u64::from(g.vcount()) >= u64::from(n) * 5);
527        }
528
529        #[test]
530        fn hex_shape_max_degree_bounded(
531            sx in 1u32..5,
532            sy in 1u32..5,
533            sz in 1u32..5,
534        ) {
535            let g = hexagonal_lattice(&[sx, sy, sz], false, false).expect("ok");
536            for v in 0..g.vcount() {
537                prop_assert!(g.degree(v).unwrap() <= 3);
538            }
539        }
540    }
541}