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 Some(&last) = prefix_sum.last() else {
209            return Err(overflow_error("empty prefix sum"));
210        };
211        let next = last
212            .checked_add(len)
213            .ok_or_else(|| overflow_error("vertex count"))?;
214        prefix_sum.push(next);
215    }
216    let Some(&vcount) = prefix_sum.last() else {
217        return Err(overflow_error("empty prefix sum"));
218    };
219
220    // Vertex index helper: (i, j) → prefix_sum[j] + (i - row_start[j]).
221    let vertex_index = |i: u32, j: usize| -> u32 { prefix_sum[j] + i - row_start[j] };
222    let row_end = |j: usize| -> u32 {
223        // row_lengths[j] >= 1 since j only ranges over non-empty rows
224        // produced by triangle_shape / rectangle_shape / hex_shape.
225        row_start[j] + row_lengths[j] - 1
226    };
227
228    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
229
230    // Try to emit the edge (i, j) → (k, l). The neighbour candidate is
231    // skipped if l is out of bounds or k falls outside the row span at
232    // l. `k_opt` is `None` when the candidate would have a negative i
233    // coordinate (the "up-left" case at column 0).
234    let add_if_in_bounds =
235        |edges: &mut Vec<(VertexId, VertexId)>, i: u32, j: usize, k_opt: Option<u32>, l: usize| {
236            let Some(k) = k_opt else {
237                return;
238            };
239            if l >= row_count {
240                return;
241            }
242            let l_start = row_start[l];
243            let l_end = row_end(l);
244            if k < l_start || k > l_end {
245                return;
246            }
247            let src = vertex_index(i, j);
248            let dst = vertex_index(k, l);
249            edges.push((src, dst));
250            if directed && mutual {
251                edges.push((dst, src));
252            }
253        };
254
255    for j in 0..row_count {
256        let row_len = row_lengths[j];
257        let start = row_start[j];
258        for i in 0..row_len {
259            let k = start + i;
260            // Right neighbour in the same row.
261            add_if_in_bounds(&mut edges, k, j, Some(k + 1), j);
262            if j + 1 < row_count {
263                // "Up" neighbour: (k, j+1).
264                add_if_in_bounds(&mut edges, k, j, Some(k), j + 1);
265                // "Up-left" neighbour: (k - 1, j + 1). Skips when k == 0.
266                add_if_in_bounds(&mut edges, k, j, k.checked_sub(1), j + 1);
267            }
268        }
269    }
270
271    let mut g = Graph::new(vcount, directed)?;
272    g.add_edges(edges)?;
273    Ok(g)
274}
275
276fn overflow_error(kind: &str) -> IgraphError {
277    IgraphError::InvalidArgument(format!("triangular_lattice: {kind} overflows u32"))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::collections::BTreeSet;
284
285    fn canonical_undirected(g: &Graph) -> BTreeSet<(u32, u32)> {
286        let mut s = BTreeSet::new();
287        for v in 0..g.vcount() {
288            for &u in &g.neighbors(v).expect("neighbors") {
289                let key = if v <= u { (v, u) } else { (u, v) };
290                s.insert(key);
291            }
292        }
293        s
294    }
295
296    fn directed_arcs(g: &Graph) -> BTreeSet<(u32, u32)> {
297        (0..u32::try_from(g.ecount()).expect("ecount fits u32"))
298            .map(|eid| g.edge(eid).expect("edge"))
299            .collect()
300    }
301
302    #[test]
303    fn empty_dims_rejected() {
304        let r = triangular_lattice(&[], false, false);
305        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
306    }
307
308    #[test]
309    fn four_dim_rejected() {
310        let r = triangular_lattice(&[1, 2, 3, 4], false, false);
311        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
312    }
313
314    #[test]
315    fn zero_dim_yields_empty_graph() {
316        let g = triangular_lattice(&[3, 0], false, false).expect("ok");
317        assert_eq!(g.vcount(), 0);
318        assert_eq!(g.ecount(), 0);
319    }
320
321    #[test]
322    fn zero_dim_directed_keeps_flag() {
323        let g = triangular_lattice(&[0, 3, 4], true, true).expect("ok");
324        assert_eq!(g.vcount(), 0);
325        assert!(g.is_directed());
326    }
327
328    #[test]
329    fn triangle_single_vertex() {
330        let g = triangular_lattice(&[1], true, false).expect("ok");
331        assert_eq!(g.vcount(), 1);
332        assert_eq!(g.ecount(), 0);
333        assert!(g.is_directed());
334    }
335
336    #[test]
337    fn triangle_side_five_matches_upstream_canon() {
338        // Reproduces references/igraph/tests/unit/igraph_triangular_lattice.out
339        // "Triangular triangular lattice" block, with vcount=15 and 30 arcs.
340        let g = triangular_lattice(&[5], true, false).expect("ok");
341        assert_eq!(g.vcount(), 15);
342        assert_eq!(g.ecount(), 30);
343        let want: BTreeSet<(u32, u32)> = [
344            (0, 1),
345            (0, 5),
346            (1, 2),
347            (1, 5),
348            (1, 6),
349            (2, 3),
350            (2, 6),
351            (2, 7),
352            (3, 4),
353            (3, 7),
354            (3, 8),
355            (4, 8),
356            (5, 6),
357            (5, 9),
358            (6, 7),
359            (6, 9),
360            (6, 10),
361            (7, 8),
362            (7, 10),
363            (7, 11),
364            (8, 11),
365            (9, 10),
366            (9, 12),
367            (10, 11),
368            (10, 12),
369            (10, 13),
370            (11, 13),
371            (12, 13),
372            (12, 14),
373            (13, 14),
374        ]
375        .into_iter()
376        .collect();
377        assert_eq!(directed_arcs(&g), want);
378    }
379
380    #[test]
381    fn rectangle_two_by_two_matches_python_igraph_undirected() {
382        // python-igraph test: Graph.Triangular_Lattice([2, 2]) gives
383        // sorted edgelist [(0,1), (0,2), (0,3), (1,3), (2,3)].
384        let g = triangular_lattice(&[2, 2], false, false).expect("ok");
385        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 2), (0, 3), (1, 3), (2, 3)]
386            .into_iter()
387            .collect();
388        assert_eq!(canonical_undirected(&g), want);
389    }
390
391    #[test]
392    fn rectangle_two_by_two_directed_mutual_doubles_edges() {
393        // python-igraph: mutual=true should emit each undirected edge twice.
394        let g = triangular_lattice(&[2, 2], true, true).expect("ok");
395        let want: BTreeSet<(u32, u32)> = [
396            (0, 1),
397            (0, 2),
398            (0, 3),
399            (1, 0),
400            (1, 3),
401            (2, 0),
402            (2, 3),
403            (3, 0),
404            (3, 1),
405            (3, 2),
406        ]
407        .into_iter()
408        .collect();
409        assert_eq!(directed_arcs(&g), want);
410        assert!(g.is_directed());
411    }
412
413    #[test]
414    fn rectangle_two_by_two_directed_unilateral_matches_undirected_set() {
415        // python-igraph: directed=true,mutual=false emits the canonical
416        // direction once — same edge set as the undirected build.
417        let g = triangular_lattice(&[2, 2], true, false).expect("ok");
418        let want: BTreeSet<(u32, u32)> = [(0, 1), (0, 2), (0, 3), (1, 3), (2, 3)]
419            .into_iter()
420            .collect();
421        assert_eq!(directed_arcs(&g), want);
422    }
423
424    #[test]
425    fn rectangle_four_by_five_matches_upstream_vcount() {
426        // References .out file: directed=true,mutual=true, vcount=20,
427        // 86 arcs (43 undirected × 2 mutual orientations).
428        let g = triangular_lattice(&[4, 5], true, true).expect("ok");
429        assert_eq!(g.vcount(), 20);
430        assert_eq!(g.ecount(), 86);
431    }
432
433    #[test]
434    fn hexagonal_3_4_5_matches_upstream_vcount() {
435        // References .out file: vcount=36, 87 undirected edges
436        // (directed=false silently collapses `mutual`).
437        let g = triangular_lattice(&[3, 4, 5], false, true).expect("ok");
438        assert_eq!(g.vcount(), 36);
439        assert_eq!(g.ecount(), 87);
440    }
441
442    #[test]
443    fn triangle_three_full_topology() {
444        // Triangle side 3: 6 vertices, 9 edges. Layout:
445        //   row 0: (0,0)=0  (1,0)=1  (2,0)=2
446        //   row 1: (0,1)=3  (1,1)=4
447        //   row 2: (0,2)=5
448        // Edges: right (0-1,1-2,3-4), up (0-3,1-4,3-5),
449        //         up-left (1-3, 2-4, 4-5).
450        let g = triangular_lattice(&[3], false, false).expect("ok");
451        assert_eq!(g.vcount(), 6);
452        assert_eq!(g.ecount(), 9);
453        let want: BTreeSet<(u32, u32)> = [
454            (0, 1),
455            (0, 3),
456            (1, 2),
457            (1, 3),
458            (1, 4),
459            (2, 4),
460            (3, 4),
461            (3, 5),
462            (4, 5),
463        ]
464        .into_iter()
465        .collect();
466        assert_eq!(canonical_undirected(&g), want);
467    }
468}
469
470#[cfg(all(test, feature = "proptest-harness"))]
471mod proptests {
472    use super::*;
473    use proptest::prelude::*;
474
475    proptest! {
476        #[test]
477        fn triangle_vcount_is_triangular_number(
478            n in 1u32..30,
479        ) {
480            let g = triangular_lattice(&[n], false, false).expect("ok");
481            let expected = (u64::from(n) * (u64::from(n) + 1)) / 2;
482            prop_assert_eq!(u64::from(g.vcount()), expected);
483        }
484
485        #[test]
486        fn rectangle_vcount_is_product(
487            sx in 1u32..15,
488            sy in 1u32..15,
489        ) {
490            let g = triangular_lattice(&[sx, sy], false, false).expect("ok");
491            prop_assert_eq!(u64::from(g.vcount()), u64::from(sx) * u64::from(sy));
492        }
493
494        #[test]
495        fn directed_mutual_doubles_undirected_ecount(
496            sx in 1u32..10,
497            sy in 1u32..10,
498        ) {
499            let undirected = triangular_lattice(&[sx, sy], false, false).expect("ok");
500            let mutual = triangular_lattice(&[sx, sy], true, true).expect("ok");
501            prop_assert_eq!(mutual.ecount(), undirected.ecount() * 2);
502        }
503
504        #[test]
505        fn directed_unilateral_matches_undirected_ecount(
506            sx in 1u32..10,
507            sy in 1u32..10,
508        ) {
509            let undirected = triangular_lattice(&[sx, sy], false, false).expect("ok");
510            let unilateral = triangular_lattice(&[sx, sy], true, false).expect("ok");
511            prop_assert_eq!(unilateral.ecount(), undirected.ecount());
512        }
513
514        #[test]
515        fn directedness_flag_round_trips(
516            sx in 1u32..8,
517            sy in 1u32..8,
518            directed: bool,
519            mutual: bool,
520        ) {
521            let g = triangular_lattice(&[sx, sy], directed, mutual).expect("ok");
522            prop_assert_eq!(g.is_directed(), directed);
523        }
524
525        #[test]
526        fn max_degree_at_most_six(
527            sx in 1u32..8,
528            sy in 1u32..8,
529        ) {
530            let g = triangular_lattice(&[sx, sy], false, false).expect("ok");
531            for v in 0..g.vcount() {
532                prop_assert!(g.degree(v).unwrap() <= 6);
533            }
534        }
535    }
536}