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) -> (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).expect("row_length non-negative by construction");
179        let start_u32 =
180            u32::try_from(row_start_val).expect("row_start non-negative by construction");
181        row_lengths.push(len_u32);
182        row_start.push(start_u32);
183
184        let ii = i64::from(i);
185        if ii < first_threshold {
186            row_length += 2;
187            row_start_val -= 2;
188        } else if ii < second_threshold {
189            if middle_shrinks {
190                row_start_val -= 2;
191            }
192        } else {
193            row_length -= 2;
194        }
195        if i == size_y - 1 {
196            row_start_val -= 1;
197            row_length += 1;
198        }
199        if i == size_z - 1 {
200            row_length += 1;
201        }
202    }
203
204    (row_lengths, row_start)
205}
206
207/// Emit edges from per-row metadata and assemble the graph.
208///
209/// Per upstream: for every vertex `(i, j)` emit:
210/// * the right neighbour `(k + 1, j)` when it sits inside row `j`'s
211///   span,
212/// * the up-left neighbour `(k - 1, j + 1)` *only* when `k` is odd and
213///   row `j + 1` exists,
214///
215/// where `k = row_start[j] + i`. Vertex id is
216/// `prefix_sum[j] + (i - row_start[j])` (= `prefix_sum[j] + i_local`).
217fn layout(
218    row_lengths: &[u32],
219    row_start: &[u32],
220    directed: bool,
221    mutual: bool,
222) -> IgraphResult<Graph> {
223    debug_assert_eq!(row_lengths.len(), row_start.len());
224    let row_count = row_lengths.len();
225
226    let mut prefix_sum: Vec<u32> = Vec::with_capacity(row_count + 1);
227    prefix_sum.push(0);
228    for &len in row_lengths {
229        let last = *prefix_sum.last().expect("non-empty");
230        let next = last
231            .checked_add(len)
232            .ok_or_else(|| overflow_error("vertex count"))?;
233        prefix_sum.push(next);
234    }
235    let vcount = *prefix_sum.last().expect("non-empty");
236
237    let vertex_index = |i: u32, j: usize| -> u32 { prefix_sum[j] + i - row_start[j] };
238    let row_end = |j: usize| -> u32 { row_start[j] + row_lengths[j] - 1 };
239
240    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
241
242    let add_if_in_bounds =
243        |edges: &mut Vec<(VertexId, VertexId)>, i: u32, j: usize, k_opt: Option<u32>, l: usize| {
244            let Some(k) = k_opt else {
245                return;
246            };
247            if l >= row_count {
248                return;
249            }
250            let l_start = row_start[l];
251            let l_end = row_end(l);
252            if k < l_start || k > l_end {
253                return;
254            }
255            let src = vertex_index(i, j);
256            let dst = vertex_index(k, l);
257            edges.push((src, dst));
258            if directed && mutual {
259                edges.push((dst, src));
260            }
261        };
262
263    for j in 0..row_count {
264        let row_len = row_lengths[j];
265        let start = row_start[j];
266        for i in 0..row_len {
267            let k = start + i;
268            // Right neighbour: always tried, succeeds iff (k+1) sits
269            // inside row j's span (so the rightmost vertex of each row
270            // never emits anything to the right).
271            add_if_in_bounds(&mut edges, k, j, Some(k + 1), j);
272            // Up-left neighbour: only for odd k and only when row j+1
273            // exists. Skips when k == 0 (no `k - 1` available).
274            if j + 1 < row_count && k % 2 == 1 {
275                add_if_in_bounds(&mut edges, k, j, k.checked_sub(1), j + 1);
276            }
277        }
278    }
279
280    let mut g = Graph::new(vcount, directed)?;
281    g.add_edges(edges)?;
282    Ok(g)
283}
284
285fn overflow_error(kind: &str) -> IgraphError {
286    IgraphError::InvalidArgument(format!("hexagonal_lattice: {kind} overflows u32"))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::collections::BTreeSet;
293
294    fn canonical_undirected(g: &Graph) -> BTreeSet<(u32, u32)> {
295        let mut s = BTreeSet::new();
296        for v in 0..g.vcount() {
297            for &u in &g.neighbors(v).expect("neighbors") {
298                let key = if v <= u { (v, u) } else { (u, v) };
299                s.insert(key);
300            }
301        }
302        s
303    }
304
305    fn directed_arcs(g: &Graph) -> BTreeSet<(u32, u32)> {
306        (0..u32::try_from(g.ecount()).expect("ecount fits u32"))
307            .map(|eid| g.edge(eid).expect("edge"))
308            .collect()
309    }
310
311    #[test]
312    fn empty_dims_rejected() {
313        let r = hexagonal_lattice(&[], false, false);
314        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
315    }
316
317    #[test]
318    fn four_dim_rejected() {
319        let r = hexagonal_lattice(&[1, 2, 3, 4], false, false);
320        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
321    }
322
323    #[test]
324    fn zero_dim_yields_empty_graph() {
325        let g = hexagonal_lattice(&[3, 0], false, false).expect("ok");
326        assert_eq!(g.vcount(), 0);
327        assert_eq!(g.ecount(), 0);
328    }
329
330    #[test]
331    fn zero_dim_directed_keeps_flag() {
332        let g = hexagonal_lattice(&[0, 3, 4], true, true).expect("ok");
333        assert_eq!(g.vcount(), 0);
334        assert!(g.is_directed());
335    }
336
337    #[test]
338    fn single_hexagon_matches_upstream_c6() {
339        // References .out: dims=[1] directed=true gives a 6-vertex C_6
340        // with 6 directed edges (canonical hexagon).
341        let g = hexagonal_lattice(&[1], true, false).expect("ok");
342        assert_eq!(g.vcount(), 6);
343        assert_eq!(g.ecount(), 6);
344        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 3), (1, 2), (2, 5), (3, 4), (4, 5)]
345            .into_iter()
346            .collect();
347        assert_eq!(directed_arcs(&g), want);
348    }
349
350    #[test]
351    fn triangular_hex_lattice_side_5_matches_upstream_vcount() {
352        // References .out: dims=[5] directed=true → vcount=46, 60 arcs.
353        let g = hexagonal_lattice(&[5], true, false).expect("ok");
354        assert_eq!(g.vcount(), 46);
355        assert_eq!(g.ecount(), 60);
356    }
357
358    #[test]
359    fn rectangle_4x5_directed_mutual_matches_upstream_vcount() {
360        // References .out: dims=[4, 5] directed=true mutual=true.
361        // The "edges:" block spans 154 lines → 77 undirected edges × 2.
362        let g = hexagonal_lattice(&[4, 5], true, true).expect("ok");
363        assert_eq!(g.vcount(), 58);
364        assert_eq!(g.ecount(), 154);
365    }
366
367    #[test]
368    fn rectangle_2x2_matches_python_igraph_undirected() {
369        // python-igraph testHexagonalLattice: Graph.Hexagonal_Lattice([2, 2])
370        // sorted edge list has 19 edges.
371        let g = hexagonal_lattice(&[2, 2], false, false).expect("ok");
372        assert_eq!(g.vcount(), 16);
373        assert_eq!(g.ecount(), 19);
374        let want: BTreeSet<(u32, u32)> = [
375            (0, 1),
376            (0, 6),
377            (1, 2),
378            (2, 3),
379            (2, 8),
380            (3, 4),
381            (4, 10),
382            (5, 6),
383            (5, 11),
384            (6, 7),
385            (7, 8),
386            (7, 13),
387            (8, 9),
388            (9, 10),
389            (9, 15),
390            (11, 12),
391            (12, 13),
392            (13, 14),
393            (14, 15),
394        ]
395        .into_iter()
396        .collect();
397        assert_eq!(canonical_undirected(&g), want);
398    }
399
400    #[test]
401    fn rectangle_2x2_directed_unilateral_matches_undirected_edges() {
402        let g = hexagonal_lattice(&[2, 2], true, false).expect("ok");
403        let want: BTreeSet<(u32, u32)> = [
404            (0, 1),
405            (0, 6),
406            (1, 2),
407            (2, 3),
408            (2, 8),
409            (3, 4),
410            (4, 10),
411            (5, 6),
412            (5, 11),
413            (6, 7),
414            (7, 8),
415            (7, 13),
416            (8, 9),
417            (9, 10),
418            (9, 15),
419            (11, 12),
420            (12, 13),
421            (13, 14),
422            (14, 15),
423        ]
424        .into_iter()
425        .collect();
426        assert_eq!(directed_arcs(&g), want);
427    }
428
429    #[test]
430    fn rectangle_2x2_directed_mutual_doubles_edges() {
431        let g = hexagonal_lattice(&[2, 2], true, true).expect("ok");
432        assert_eq!(g.ecount(), 19 * 2);
433        assert!(g.is_directed());
434    }
435
436    #[test]
437    fn hexagonal_3_4_5_matches_upstream_vcount() {
438        // References .out: dims=[3, 4, 5] directed=false mutual=true →
439        // vcount=94 with 129 undirected edges (directed=false silently
440        // collapses mutual; the .out edge block is 129 lines).
441        let g = hexagonal_lattice(&[3, 4, 5], false, true).expect("ok");
442        assert_eq!(g.vcount(), 94);
443        assert_eq!(g.ecount(), 129);
444        // Every vertex degree ≤ 3 (hexagonal lattice invariant).
445        for v in 0..g.vcount() {
446            assert!(g.degree(v).expect("deg") <= 3);
447        }
448    }
449
450    #[test]
451    fn all_vertices_have_degree_at_most_three() {
452        for &(sx, sy, sz) in &[(3u32, 4u32, 5u32), (2, 3, 4), (5, 5, 5)] {
453            let g = hexagonal_lattice(&[sx, sy, sz], false, false).expect("ok");
454            for v in 0..g.vcount() {
455                assert!(g.degree(v).expect("deg") <= 3);
456            }
457        }
458    }
459}
460
461#[cfg(all(test, feature = "proptest-harness"))]
462mod proptests {
463    use super::*;
464    use proptest::prelude::*;
465
466    proptest! {
467        #[test]
468        fn directed_mutual_doubles_undirected_ecount(
469            sx in 1u32..6,
470            sy in 1u32..6,
471        ) {
472            let undirected = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
473            let mutual = hexagonal_lattice(&[sx, sy], true, true).expect("ok");
474            prop_assert_eq!(mutual.ecount(), undirected.ecount() * 2);
475        }
476
477        #[test]
478        fn directed_unilateral_matches_undirected_ecount(
479            sx in 1u32..6,
480            sy in 1u32..6,
481        ) {
482            let undirected = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
483            let unilateral = hexagonal_lattice(&[sx, sy], true, false).expect("ok");
484            prop_assert_eq!(unilateral.ecount(), undirected.ecount());
485        }
486
487        #[test]
488        fn directedness_flag_round_trips(
489            sx in 1u32..6,
490            sy in 1u32..6,
491            directed: bool,
492            mutual: bool,
493        ) {
494            let g = hexagonal_lattice(&[sx, sy], directed, mutual).expect("ok");
495            prop_assert_eq!(g.is_directed(), directed);
496        }
497
498        #[test]
499        fn max_degree_at_most_three(
500            sx in 1u32..6,
501            sy in 1u32..6,
502        ) {
503            let g = hexagonal_lattice(&[sx, sy], false, false).expect("ok");
504            for v in 0..g.vcount() {
505                prop_assert!(g.degree(v).unwrap() <= 3);
506            }
507        }
508
509        #[test]
510        fn triangle_shape_vcount_grows_quadratically(
511            n in 1u32..8,
512        ) {
513            // Triangular outline vcount is the sum of the per-row lengths.
514            // Empirically: side 1 → 6, side 2 → 13, side 3 → 22,
515            // side 4 → 33, side 5 → 46 (matches upstream .out).
516            let g = hexagonal_lattice(&[n], false, false).expect("ok");
517            // Closed form: n*(2n+5) + (2n+1)+(2n-1) = n^2 + (n+1)*(2n+3)
518            // − but rather than fight the formula, assert lower/upper bound.
519            prop_assert!(u64::from(g.vcount()) >= u64::from(n) * 5);
520        }
521
522        #[test]
523        fn hex_shape_max_degree_bounded(
524            sx in 1u32..5,
525            sy in 1u32..5,
526            sz in 1u32..5,
527        ) {
528            let g = hexagonal_lattice(&[sx, sy, sz], false, false).expect("ok");
529            for v in 0..g.vcount() {
530                prop_assert!(g.degree(v).unwrap() <= 3);
531            }
532        }
533    }
534}