Skip to main content

rust_igraph/algorithms/constructors/
triangular_lattice.rs

1//! Triangular lattice constructor (ALGO-CN-023).
2//!
3//! Counterpart of `igraph_triangular_lattice()` in
4//! `references/igraph/src/constructors/lattices.c:290-319`.
5//!
6//! Builds a planar triangular lattice whose vertices have coordinates
7//! `(i, j)` for non-negative integers and where each `(i, j)` is
8//! connected to `(i + 1, j)`, `(i, j + 1)`, and `(i - 1, j + 1)`
9//! whenever those neighbours exist on the lattice. Every vertex thus
10//! has degree at most six, and the graph is the planar dual of the
11//! hexagonal lattice over the same `dims`.
12//!
13//! The `dims` slice doubles as a *shape* selector with three modes:
14//!
15//! * `[n]`           — triangle with side length `n`
16//! * `[size_x, size_y]` — quasi-rectangle, `size_x` rows of `size_y`
17//!   vertices each
18//! * `[size_x, size_y, size_z]` — hexagon with side lengths
19//!   `size_x`, `size_y`, `size_z`
20//!
21//! Vertices are numbered lexicographically with the second coordinate
22//! more significant (the C `lex_ordering = false` path), matching the
23//! upstream library.
24//!
25//! Modes:
26//!
27//! * `directed = false`: every undirected lattice edge is emitted once
28//!   with the lower vertex id as the source.
29//! * `directed = true, mutual = false`: each lattice edge becomes a
30//!   single arc oriented from the lower vertex id to the higher.
31//! * `directed = true, mutual = true`: every undirected lattice edge
32//!   becomes a pair of opposite arcs (the canonical orientation plus
33//!   its reverse), matching the C `ADD_EDGE_IJ_KL_IF_EXISTS` macro's
34//!   double push.
35//!
36//! Special cases:
37//!
38//! * `dims = []` and `dims = [...]` with any zero component both
39//!   collapse to the empty graph (`vcount = 0`), matching the upstream
40//!   "if `vector_int_contains(dims, 0)` return `igraph_empty`" guard.
41//! * `dims.len() != 1, 2, 3` is rejected with [`IgraphError::InvalidArgument`].
42//!
43//! Algorithm: build per-row `row_lengths` and `row_start` arrays from
44//! the requested shape, then walk every lattice vertex and emit the
45//! three canonical "forward" neighbours that fall inside the lattice.
46//! Boundary checks reproduce the upstream `ADD_EDGE_IJ_KL_IF_EXISTS`
47//! macro byte-for-byte. The vertex index for `(i, j)` is
48//! `prefix_sum[j] + (i - row_start[j])`.
49//!
50//! Time complexity: `O(|V| + |E|)`.
51
52use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
53
54/// Build a triangular lattice with the requested shape.
55///
56/// See the module documentation for the meaning of `dims`,
57/// `directed`, and `mutual`.
58///
59/// # Errors
60///
61/// * [`IgraphError::InvalidArgument`] — when:
62///   * `dims.len()` is not in `{1, 2, 3}` (the C
63///     `IGRAPH_EINVAL: "size of dimension vector must be 1, 2 or 3"`
64///     path),
65///   * the implied vertex or edge count overflows `u32`.
66///
67/// The upstream "negative dimension" path is eliminated at the type
68/// level by taking `&[u32]`.
69///
70/// # Examples
71///
72/// ```
73/// use rust_igraph::triangular_lattice;
74///
75/// // Triangle with side 5: 1 + 2 + 3 + 4 + 5 = 15 vertices.
76/// let g = triangular_lattice(&[5], false, false).unwrap();
77/// assert_eq!(g.vcount(), 15);
78/// assert_eq!(g.ecount(), 30);
79///
80/// // 2 x 2 "rectangle": four vertices, five edges (matches python-igraph).
81/// let g = triangular_lattice(&[2, 2], false, false).unwrap();
82/// assert_eq!(g.vcount(), 4);
83/// assert_eq!(g.ecount(), 5);
84///
85/// // A zero in any coordinate collapses to the empty graph.
86/// let g = triangular_lattice(&[3, 0], false, false).unwrap();
87/// assert_eq!(g.vcount(), 0);
88/// ```
89pub fn triangular_lattice(dims: &[u32], directed: bool, mutual: bool) -> IgraphResult<Graph> {
90    if !matches!(dims.len(), 1..=3) {
91        return Err(IgraphError::InvalidArgument(format!(
92            "triangular_lattice: size of dimension vector must be 1, 2 or 3, got {}",
93            dims.len()
94        )));
95    }
96
97    // Upstream short-circuit: any zero dim → empty graph.
98    if dims.contains(&0) {
99        return Graph::new(0, directed);
100    }
101
102    let (row_lengths, row_start) = match dims.len() {
103        1 => triangle_shape(dims[0]),
104        2 => rectangle_shape(dims[0], dims[1]),
105        3 => hex_shape(dims[0], dims[1], dims[2]),
106        _ => unreachable!("dims length already checked"),
107    };
108
109    layout(&row_lengths, &row_start, directed, mutual)
110}
111
112/// Compute `(row_lengths, row_start)` for the triangular-triangle shape.
113///
114/// Row `j` has `n - j` vertices starting at `0`, so the resulting
115/// triangle has `n(n+1)/2` vertices in total.
116fn triangle_shape(n: u32) -> (Vec<u32>, Vec<u32>) {
117    let mut row_lengths = Vec::with_capacity(n as usize);
118    let mut row_start = Vec::with_capacity(n as usize);
119    for j in 0..n {
120        row_lengths.push(n - j);
121        row_start.push(0);
122    }
123    (row_lengths, row_start)
124}
125
126/// Compute `(row_lengths, row_start)` for the rectangular shape.
127///
128/// `size_x` rows of `size_y` vertices each. Row `j` starts at
129/// `(row_count - j) / 2` (matching `igraph` integer division), giving
130/// the lattice its characteristic skewed-rectangle outline.
131fn rectangle_shape(size_x: u32, size_y: u32) -> (Vec<u32>, Vec<u32>) {
132    let mut row_lengths = Vec::with_capacity(size_x as usize);
133    let mut row_start = Vec::with_capacity(size_x as usize);
134    for j in 0..size_x {
135        row_lengths.push(size_y);
136        row_start.push((size_x - j) / 2);
137    }
138    (row_lengths, row_start)
139}
140
141/// Compute `(row_lengths, row_start)` for the hexagonal shape.
142///
143/// `row_count = size_y + size_z - 1`. The first `size_y - 1` rows
144/// grow the row length by one and shrink the row start by one
145/// (expanding side `size_y`); the middle band of `|size_y - size_z|`
146/// rows applies a sign-flagged shift to `row_start`; the final rows
147/// shrink the row length back (contracting side `size_z`).
148///
149/// All intermediate values stay non-negative by construction:
150/// `row_start_val` starts at `sy - 1` and decreases by at most
151/// `sy.min(sz) - 1` in phase 1, leaving `sy - sy.min(sz) ≥ 0`;
152/// phase 2 only decreases it when `sy >= sz`, by `sy - sz`, leaving
153/// `0`; phase 3 doesn't touch `row_start_val`. Similarly `row_length`
154/// grows in phase 1 and shrinks back in phase 3 to its starting value.
155fn hex_shape(size_x: u32, size_y: u32, size_z: u32) -> (Vec<u32>, Vec<u32>) {
156    // Caller has already filtered any zero dim out, so subtractions are safe.
157    let row_count = size_y + size_z - 1;
158
159    let mut row_length: u32 = size_x;
160    let mut row_start_val: u32 = size_y - 1;
161    let first_threshold: u32 = size_y.min(size_z) - 1;
162    let second_threshold: u32 = size_y.max(size_z) - 1;
163    let shrink_in_middle: bool = size_y >= size_z;
164
165    let mut row_lengths = Vec::with_capacity(row_count as usize);
166    let mut row_start = Vec::with_capacity(row_count as usize);
167
168    for i in 0..row_count {
169        row_lengths.push(row_length);
170        row_start.push(row_start_val);
171
172        if i < first_threshold {
173            row_length += 1;
174            row_start_val -= 1;
175        } else if i < second_threshold {
176            if shrink_in_middle {
177                row_start_val -= 1;
178            }
179        } else {
180            row_length -= 1;
181        }
182    }
183
184    (row_lengths, row_start)
185}
186
187/// Lay out the lattice from per-row metadata, emit edges, and build
188/// the graph.
189///
190/// Mirrors the upstream `triangular_lattice()` helper (with
191/// `lex_ordering = false`). The vertex index for `(i, j)` is
192/// `prefix_sum[j] + (i - row_start[j])`. For every vertex we try the
193/// three "forward" lattice neighbours and only emit those that fall
194/// inside the lattice.
195fn layout(
196    row_lengths: &[u32],
197    row_start: &[u32],
198    directed: bool,
199    mutual: bool,
200) -> IgraphResult<Graph> {
201    debug_assert_eq!(row_lengths.len(), row_start.len());
202    let row_count = row_lengths.len();
203
204    // Prefix sum of row lengths → vertex index base for each row.
205    let mut prefix_sum: Vec<u32> = Vec::with_capacity(row_count + 1);
206    prefix_sum.push(0);
207    for &len in row_lengths {
208        let last = *prefix_sum.last().expect("non-empty");
209        let next = last
210            .checked_add(len)
211            .ok_or_else(|| overflow_error("vertex count"))?;
212        prefix_sum.push(next);
213    }
214    let vcount = *prefix_sum.last().expect("non-empty");
215
216    // Vertex index helper: (i, j) → prefix_sum[j] + (i - row_start[j]).
217    let vertex_index = |i: u32, j: usize| -> u32 { prefix_sum[j] + i - row_start[j] };
218    let row_end = |j: usize| -> u32 {
219        // row_lengths[j] >= 1 since j only ranges over non-empty rows
220        // produced by triangle_shape / rectangle_shape / hex_shape.
221        row_start[j] + row_lengths[j] - 1
222    };
223
224    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
225
226    // Try to emit the edge (i, j) → (k, l). The neighbour candidate is
227    // skipped if l is out of bounds or k falls outside the row span at
228    // l. `k_opt` is `None` when the candidate would have a negative i
229    // coordinate (the "up-left" case at column 0).
230    let add_if_in_bounds =
231        |edges: &mut Vec<(VertexId, VertexId)>, i: u32, j: usize, k_opt: Option<u32>, l: usize| {
232            let Some(k) = k_opt else {
233                return;
234            };
235            if l >= row_count {
236                return;
237            }
238            let l_start = row_start[l];
239            let l_end = row_end(l);
240            if k < l_start || k > l_end {
241                return;
242            }
243            let src = vertex_index(i, j);
244            let dst = vertex_index(k, l);
245            edges.push((src, dst));
246            if directed && mutual {
247                edges.push((dst, src));
248            }
249        };
250
251    for j in 0..row_count {
252        let row_len = row_lengths[j];
253        let start = row_start[j];
254        for i in 0..row_len {
255            let k = start + i;
256            // Right neighbour in the same row.
257            add_if_in_bounds(&mut edges, k, j, Some(k + 1), j);
258            if j + 1 < row_count {
259                // "Up" neighbour: (k, j+1).
260                add_if_in_bounds(&mut edges, k, j, Some(k), j + 1);
261                // "Up-left" neighbour: (k - 1, j + 1). Skips when k == 0.
262                add_if_in_bounds(&mut edges, k, j, k.checked_sub(1), j + 1);
263            }
264        }
265    }
266
267    let mut g = Graph::new(vcount, directed)?;
268    g.add_edges(edges)?;
269    Ok(g)
270}
271
272fn overflow_error(kind: &str) -> IgraphError {
273    IgraphError::InvalidArgument(format!("triangular_lattice: {kind} overflows u32"))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::collections::BTreeSet;
280
281    fn canonical_undirected(g: &Graph) -> BTreeSet<(u32, u32)> {
282        let mut s = BTreeSet::new();
283        for v in 0..g.vcount() {
284            for &u in &g.neighbors(v).expect("neighbors") {
285                let key = if v <= u { (v, u) } else { (u, v) };
286                s.insert(key);
287            }
288        }
289        s
290    }
291
292    fn directed_arcs(g: &Graph) -> BTreeSet<(u32, u32)> {
293        (0..u32::try_from(g.ecount()).expect("ecount fits u32"))
294            .map(|eid| g.edge(eid).expect("edge"))
295            .collect()
296    }
297
298    #[test]
299    fn empty_dims_rejected() {
300        let r = triangular_lattice(&[], false, false);
301        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
302    }
303
304    #[test]
305    fn four_dim_rejected() {
306        let r = triangular_lattice(&[1, 2, 3, 4], false, false);
307        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
308    }
309
310    #[test]
311    fn zero_dim_yields_empty_graph() {
312        let g = triangular_lattice(&[3, 0], false, false).expect("ok");
313        assert_eq!(g.vcount(), 0);
314        assert_eq!(g.ecount(), 0);
315    }
316
317    #[test]
318    fn zero_dim_directed_keeps_flag() {
319        let g = triangular_lattice(&[0, 3, 4], true, true).expect("ok");
320        assert_eq!(g.vcount(), 0);
321        assert!(g.is_directed());
322    }
323
324    #[test]
325    fn triangle_single_vertex() {
326        let g = triangular_lattice(&[1], true, false).expect("ok");
327        assert_eq!(g.vcount(), 1);
328        assert_eq!(g.ecount(), 0);
329        assert!(g.is_directed());
330    }
331
332    #[test]
333    fn triangle_side_five_matches_upstream_canon() {
334        // Reproduces references/igraph/tests/unit/igraph_triangular_lattice.out
335        // "Triangular triangular lattice" block, with vcount=15 and 30 arcs.
336        let g = triangular_lattice(&[5], true, false).expect("ok");
337        assert_eq!(g.vcount(), 15);
338        assert_eq!(g.ecount(), 30);
339        let want: BTreeSet<(u32, u32)> = [
340            (0, 1),
341            (0, 5),
342            (1, 2),
343            (1, 5),
344            (1, 6),
345            (2, 3),
346            (2, 6),
347            (2, 7),
348            (3, 4),
349            (3, 7),
350            (3, 8),
351            (4, 8),
352            (5, 6),
353            (5, 9),
354            (6, 7),
355            (6, 9),
356            (6, 10),
357            (7, 8),
358            (7, 10),
359            (7, 11),
360            (8, 11),
361            (9, 10),
362            (9, 12),
363            (10, 11),
364            (10, 12),
365            (10, 13),
366            (11, 13),
367            (12, 13),
368            (12, 14),
369            (13, 14),
370        ]
371        .into_iter()
372        .collect();
373        assert_eq!(directed_arcs(&g), want);
374    }
375
376    #[test]
377    fn rectangle_two_by_two_matches_python_igraph_undirected() {
378        // python-igraph test: Graph.Triangular_Lattice([2, 2]) gives
379        // sorted edgelist [(0,1), (0,2), (0,3), (1,3), (2,3)].
380        let g = triangular_lattice(&[2, 2], false, false).expect("ok");
381        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 2), (0, 3), (1, 3), (2, 3)]
382            .into_iter()
383            .collect();
384        assert_eq!(canonical_undirected(&g), want);
385    }
386
387    #[test]
388    fn rectangle_two_by_two_directed_mutual_doubles_edges() {
389        // python-igraph: mutual=true should emit each undirected edge twice.
390        let g = triangular_lattice(&[2, 2], true, true).expect("ok");
391        let want: BTreeSet<(u32, u32)> = [
392            (0, 1),
393            (0, 2),
394            (0, 3),
395            (1, 0),
396            (1, 3),
397            (2, 0),
398            (2, 3),
399            (3, 0),
400            (3, 1),
401            (3, 2),
402        ]
403        .into_iter()
404        .collect();
405        assert_eq!(directed_arcs(&g), want);
406        assert!(g.is_directed());
407    }
408
409    #[test]
410    fn rectangle_two_by_two_directed_unilateral_matches_undirected_set() {
411        // python-igraph: directed=true,mutual=false emits the canonical
412        // direction once — same edge set as the undirected build.
413        let g = triangular_lattice(&[2, 2], true, false).expect("ok");
414        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 2), (0, 3), (1, 3), (2, 3)]
415            .into_iter()
416            .collect();
417        assert_eq!(directed_arcs(&g), want);
418    }
419
420    #[test]
421    fn rectangle_four_by_five_matches_upstream_vcount() {
422        // References .out file: directed=true,mutual=true, vcount=20,
423        // 86 arcs (43 undirected × 2 mutual orientations).
424        let g = triangular_lattice(&[4, 5], true, true).expect("ok");
425        assert_eq!(g.vcount(), 20);
426        assert_eq!(g.ecount(), 86);
427    }
428
429    #[test]
430    fn hexagonal_3_4_5_matches_upstream_vcount() {
431        // References .out file: vcount=36, 87 undirected edges
432        // (directed=false silently collapses `mutual`).
433        let g = triangular_lattice(&[3, 4, 5], false, true).expect("ok");
434        assert_eq!(g.vcount(), 36);
435        assert_eq!(g.ecount(), 87);
436    }
437
438    #[test]
439    fn triangle_three_full_topology() {
440        // Triangle side 3: 6 vertices, 9 edges. Layout:
441        //   row 0: (0,0)=0  (1,0)=1  (2,0)=2
442        //   row 1: (0,1)=3  (1,1)=4
443        //   row 2: (0,2)=5
444        // Edges: right (0-1,1-2,3-4), up (0-3,1-4,3-5),
445        //         up-left (1-3, 2-4, 4-5).
446        let g = triangular_lattice(&[3], false, false).expect("ok");
447        assert_eq!(g.vcount(), 6);
448        assert_eq!(g.ecount(), 9);
449        let want: BTreeSet<(u32, u32)> = [
450            (0, 1),
451            (0, 3),
452            (1, 2),
453            (1, 3),
454            (1, 4),
455            (2, 4),
456            (3, 4),
457            (3, 5),
458            (4, 5),
459        ]
460        .into_iter()
461        .collect();
462        assert_eq!(canonical_undirected(&g), want);
463    }
464}
465
466#[cfg(all(test, feature = "proptest-harness"))]
467mod proptests {
468    use super::*;
469    use proptest::prelude::*;
470
471    proptest! {
472        #[test]
473        fn triangle_vcount_is_triangular_number(
474            n in 1u32..30,
475        ) {
476            let g = triangular_lattice(&[n], false, false).expect("ok");
477            let expected = (u64::from(n) * (u64::from(n) + 1)) / 2;
478            prop_assert_eq!(u64::from(g.vcount()), expected);
479        }
480
481        #[test]
482        fn rectangle_vcount_is_product(
483            sx in 1u32..15,
484            sy in 1u32..15,
485        ) {
486            let g = triangular_lattice(&[sx, sy], false, false).expect("ok");
487            prop_assert_eq!(u64::from(g.vcount()), u64::from(sx) * u64::from(sy));
488        }
489
490        #[test]
491        fn directed_mutual_doubles_undirected_ecount(
492            sx in 1u32..10,
493            sy in 1u32..10,
494        ) {
495            let undirected = triangular_lattice(&[sx, sy], false, false).expect("ok");
496            let mutual = triangular_lattice(&[sx, sy], true, true).expect("ok");
497            prop_assert_eq!(mutual.ecount(), undirected.ecount() * 2);
498        }
499
500        #[test]
501        fn directed_unilateral_matches_undirected_ecount(
502            sx in 1u32..10,
503            sy in 1u32..10,
504        ) {
505            let undirected = triangular_lattice(&[sx, sy], false, false).expect("ok");
506            let unilateral = triangular_lattice(&[sx, sy], true, false).expect("ok");
507            prop_assert_eq!(unilateral.ecount(), undirected.ecount());
508        }
509
510        #[test]
511        fn directedness_flag_round_trips(
512            sx in 1u32..8,
513            sy in 1u32..8,
514            directed: bool,
515            mutual: bool,
516        ) {
517            let g = triangular_lattice(&[sx, sy], directed, mutual).expect("ok");
518            prop_assert_eq!(g.is_directed(), directed);
519        }
520
521        #[test]
522        fn max_degree_at_most_six(
523            sx in 1u32..8,
524            sy in 1u32..8,
525        ) {
526            let g = triangular_lattice(&[sx, sy], false, false).expect("ok");
527            for v in 0..g.vcount() {
528                prop_assert!(g.degree(v).unwrap() <= 6);
529            }
530        }
531    }
532}