Skip to main content

rust_igraph/algorithms/constructors/
hamming.rs

1//! Hamming graph constructor (ALGO-CN-008).
2//!
3//! Counterpart of `igraph_hamming()` in
4//! `references/igraph/src/constructors/regular.c:1070-1153`.
5//!
6//! The d-dimensional **Hamming graph** `H(n, q)` over an alphabet of
7//! size `q` has `q^n` vertices indexed by all length-`n` strings in
8//! `{0, 1, …, q-1}^n`. Two vertices are connected iff their strings
9//! differ in **exactly one position**.
10//!
11//! The string `(x_0, x_1, …, x_{n-1})` maps to vertex id
12//! `Σ_i x_i · q^i` (little-endian base-`q` representation), matching
13//! the upstream convention.
14//!
15//! Edge count: `q^n · (q − 1) · n / 2`.
16//!
17//! Special cases:
18//!
19//! * `n = 0` → singleton (one vertex), even when `q = 0`.
20//! * `n > 0` and `q = 0` → null graph (zero vertices).
21//! * `q = 1` → singleton (the only string is the all-zero one, no edges).
22//! * `q = 2` → identical to the n-dimensional hypercube `Q_n` (see
23//!   [`crate::hypercube`]).
24//!
25//! Algorithm: enumerate every vertex `v ∈ [0, q^n)`; for each digit
26//! position `i ∈ [0, n)` extract `dig = (v / q^i) mod q`, then for
27//! every higher digit value `dig + j` with `j ∈ [1, q − dig)` emit the
28//! canonical edge `(v, v + j · q^i)`. Because we only walk upward in
29//! each digit, every edge is produced exactly once (lower endpoint
30//! first). Mirrors the upstream C loop.
31//!
32//! Time complexity: `O(n · q^(n+1))` — for each of the `q^n` vertices
33//! we iterate `n` digit positions and emit up to `q − 1` outgoing
34//! upward-pointing edges per digit.
35
36use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
37
38/// Build the d-dimensional Hamming graph `H(n, q)`.
39///
40/// Returns a graph with `q^n` vertices where two vertices share an
41/// edge iff their base-`q` digit representations differ in exactly one
42/// position. For the directed variant every edge points from the
43/// lower-indexed endpoint to the higher-indexed one (the canonical
44/// orientation used by upstream igraph).
45///
46/// # Edge cases
47///
48/// * `n = 0` returns the singleton (one vertex, zero edges), regardless
49///   of `q`.
50/// * `n > 0` and `q = 0` returns the null graph (zero vertices, zero
51///   edges).
52/// * `q = 1` returns the singleton.
53/// * `q = 2` produces `Q_n`, equivalent to [`crate::hypercube`].
54///
55/// # Errors
56///
57/// * [`IgraphError::InvalidArgument`] — `q^n` overflows `u32`, or the
58///   edge count overflows `usize`.
59///
60/// # Examples
61///
62/// ```
63/// use rust_igraph::hamming;
64///
65/// // H(2, 3) — 9 vertices, 18 edges, 4-regular.
66/// let g = hamming(2, 3, false).unwrap();
67/// assert_eq!(g.vcount(), 9);
68/// assert_eq!(g.ecount(), 18);
69/// ```
70pub fn hamming(n: u32, q: u32, directed: bool) -> IgraphResult<Graph> {
71    // Singleton in the zero-dimensional case, even when q == 0.
72    if n == 0 {
73        return Graph::new(1, directed);
74    }
75    // Null graph for an empty alphabet (with n > 0).
76    if q == 0 {
77        return Graph::new(0, directed);
78    }
79    // q == 1: every "string" is the all-zero one — singleton.
80    if q == 1 {
81        return Graph::new(1, directed);
82    }
83
84    // vcount = q^n; overflow-safe via checked_pow.
85    let vcount: u32 = q.checked_pow(n).ok_or_else(|| {
86        IgraphError::InvalidArgument(format!(
87            "hamming: parameters n={n}, q={q} overflow u32 vertex count (q^n)"
88        ))
89    })?;
90
91    // ecount = q^n · (q − 1) · n / 2. The product (q−1)·n fits because
92    // (q−1)·n < q^n once n ≥ 1 and q ≥ 2 (so it cannot overflow at this
93    // point, mirroring the upstream comment). The full multiplication
94    // is still guarded with checked ops in case future bound changes
95    // shift the invariant.
96    let qm1: u64 = u64::from(q) - 1;
97    let n_u64: u64 = u64::from(n);
98    let vc_u64: u64 = u64::from(vcount);
99    let prod = qm1
100        .checked_mul(n_u64)
101        .ok_or_else(|| {
102            IgraphError::InvalidArgument(format!("hamming: (q-1)*n overflows u64 for n={n}, q={q}"))
103        })?
104        .checked_mul(vc_u64)
105        .ok_or_else(|| {
106            IgraphError::InvalidArgument(format!(
107                "hamming: ecount product overflows u64 for n={n}, q={q}"
108            ))
109        })?;
110    // Always even because either q is even (vcount even) or (q-1) is
111    // even — but we still divide rather than rely on it.
112    debug_assert!(prod % 2 == 0, "hamming ecount product must be even");
113    let ecount: usize = usize::try_from(prod / 2).map_err(|_| {
114        IgraphError::InvalidArgument(format!(
115            "hamming: edge count {} overflows usize for n={n}, q={q}",
116            prod / 2
117        ))
118    })?;
119
120    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(ecount);
121    for v in 0..vcount {
122        // Walk digits of v in base q. `pos = q^i` selects the i-th digit.
123        let mut pos: u32 = 1;
124        for _i in 0..n {
125            let dig = (v / pos) % q;
126            // For each higher value of this digit, emit canonical edge
127            // (v, v + j*pos) — guaranteed to land within [0, q^n) and
128            // strictly greater than v because only this digit changes
129            // and it grows.
130            let mut j: u32 = 1;
131            while j < q - dig {
132                let u = v + j * pos;
133                edges.push((v, u));
134                j += 1;
135            }
136            // pos *= q. At the last iteration this can overflow u32
137            // (when q^n hits the boundary), so use checked_mul; if it
138            // overflows, we are at the very last digit and won't use
139            // the result again — guard accordingly.
140            if let Some(next_pos) = pos.checked_mul(q) {
141                pos = next_pos;
142            } else {
143                break;
144            }
145        }
146    }
147
148    let mut g = Graph::new(vcount, directed)?;
149    g.add_edges(edges)?;
150    Ok(g)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn dump_edges(g: &Graph) -> Vec<(u32, u32)> {
158        let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
159        (0..m)
160            .map(|e| g.edge(e).expect("edge id in bounds"))
161            .collect()
162    }
163
164    fn degree_of(g: &Graph, v: VertexId) -> usize {
165        g.degree(v).expect("vertex in range")
166    }
167
168    #[test]
169    fn n0_is_singleton() {
170        // n=0 — singleton regardless of q.
171        for q in [0u32, 1, 2, 5, 100] {
172            let g = hamming(0, q, false).expect("n=0");
173            assert_eq!(g.vcount(), 1, "n=0, q={q}");
174            assert_eq!(g.ecount(), 0, "n=0, q={q}");
175        }
176    }
177
178    #[test]
179    fn q0_with_positive_n_is_null_graph() {
180        let g = hamming(3, 0, false).expect("q=0");
181        assert_eq!(g.vcount(), 0);
182        assert_eq!(g.ecount(), 0);
183    }
184
185    #[test]
186    fn q1_is_singleton() {
187        // Only one string (the all-zero one), no edges.
188        for n in [1u32, 3, 7] {
189            let g = hamming(n, 1, false).expect("q=1");
190            assert_eq!(g.vcount(), 1, "n={n}, q=1");
191            assert_eq!(g.ecount(), 0, "n={n}, q=1");
192        }
193    }
194
195    #[test]
196    fn h_n1_q3_is_complete_k3() {
197        // H(1, 3): 3 strings of length 1, every two differ in their
198        // single position — that's K_3.
199        let g = hamming(1, 3, false).expect("H(1,3)");
200        assert_eq!(g.vcount(), 3);
201        assert_eq!(g.ecount(), 3);
202        assert_eq!(dump_edges(&g), vec![(0, 1), (0, 2), (1, 2)]);
203    }
204
205    #[test]
206    fn h_n2_q3_matches_upstream_edges() {
207        // H(2, 3): 9 vertices, q^n·(q-1)·n/2 = 9·2·2/2 = 18 edges.
208        let g = hamming(2, 3, false).expect("H(2,3)");
209        assert_eq!(g.vcount(), 9);
210        assert_eq!(g.ecount(), 18);
211        // Hand-computed canonical edge list following the upstream
212        // digit-walk order: for v=0..8, digit 0 (j=1,2), then digit 1
213        // (j=1,2) where applicable.
214        assert_eq!(
215            dump_edges(&g),
216            vec![
217                (0, 1),
218                (0, 2),
219                (0, 3),
220                (0, 6), // v=0
221                (1, 2),
222                (1, 4),
223                (1, 7), // v=1
224                (2, 5),
225                (2, 8), // v=2
226                (3, 4),
227                (3, 5),
228                (3, 6), // v=3
229                (4, 5),
230                (4, 7), // v=4
231                (5, 8), // v=5
232                (6, 7),
233                (6, 8), // v=6
234                (7, 8), // v=7
235                        // v=8 → no higher edge
236            ]
237        );
238    }
239
240    #[test]
241    fn h_n2_q3_is_four_regular() {
242        // Every vertex in H(n, q) has degree n*(q-1).
243        let g = hamming(2, 3, false).expect("H(2,3)");
244        for v in 0..g.vcount() {
245            assert_eq!(
246                degree_of(&g, v),
247                4,
248                "vertex {v} of H(2,3) should have degree 4"
249            );
250        }
251    }
252
253    #[test]
254    fn q2_equals_hypercube_q3() {
255        // H(n, 2) ≡ Q_n. Compare against the standalone hypercube.
256        use crate::hypercube;
257        for n in 0u32..=4 {
258            let h = hamming(n, 2, false).expect("H(n,2)");
259            let q = hypercube(n, false).expect("Q_n");
260            assert_eq!(h.vcount(), q.vcount(), "n={n} vcount");
261            assert_eq!(h.ecount(), q.ecount(), "n={n} ecount");
262            assert_eq!(dump_edges(&h), dump_edges(&q), "n={n} edge sequence");
263        }
264    }
265
266    #[test]
267    fn directed_emits_low_to_high_arcs() {
268        let g = hamming(2, 3, true).expect("H(2,3) directed");
269        assert!(g.is_directed());
270        assert_eq!(g.ecount(), 18);
271        let m = u32::try_from(g.ecount()).unwrap();
272        for e in 0..m {
273            let (u, v) = g.edge(e).unwrap();
274            assert!(u < v, "edge ({u}, {v}) must be canonically ordered");
275        }
276    }
277
278    #[test]
279    fn h_n3_q2_matches_hypercube_q3() {
280        // Spot check: H(3, 2) === Q_3 (8 vertices, 12 edges).
281        let g = hamming(3, 2, false).expect("H(3,2)");
282        assert_eq!(g.vcount(), 8);
283        assert_eq!(g.ecount(), 12);
284    }
285
286    #[test]
287    #[allow(clippy::many_single_char_names)]
288    fn edges_differ_in_one_digit() {
289        // For every edge of H(2, 4), the two endpoints differ in exactly
290        // one base-4 digit.
291        let n: u32 = 2;
292        let q: u32 = 4;
293        let g = hamming(n, q, false).expect("H(2,4)");
294        let m = u32::try_from(g.ecount()).unwrap();
295        for e in 0..m {
296            let (u, v) = g.edge(e).unwrap();
297            let mut diffs = 0u32;
298            let mut a = u;
299            let mut b = v;
300            for _ in 0..n {
301                if a % q != b % q {
302                    diffs += 1;
303                }
304                a /= q;
305                b /= q;
306            }
307            assert_eq!(
308                diffs, 1,
309                "edge ({u}, {v}) in H({n},{q}) must differ in one digit"
310            );
311        }
312    }
313
314    #[test]
315    fn vertex_count_overflow_rejected() {
316        // q = 2, n = 32 → 2^32 overflows u32.
317        let err = hamming(32, 2, false).expect_err("2^32 overflow");
318        match err {
319            IgraphError::InvalidArgument(msg) => assert!(msg.contains("overflow")),
320            other => panic!("unexpected error: {other:?}"),
321        }
322    }
323
324    #[test]
325    fn vertex_count_overflow_high_alphabet_rejected() {
326        // q = 100, n = 5 → 100^5 = 10^10 overflows u32.
327        let err = hamming(5, 100, false).expect_err("100^5 overflow");
328        match err {
329            IgraphError::InvalidArgument(msg) => assert!(msg.contains("overflow")),
330            other => panic!("unexpected error: {other:?}"),
331        }
332    }
333}
334
335#[cfg(all(test, feature = "proptest-harness"))]
336mod proptests {
337    use super::*;
338    use proptest::prelude::*;
339
340    proptest! {
341        // For every reasonable (n, q), the Hamming graph H(n, q) is
342        // n*(q-1)-regular (or 0-regular when n=0 or q≤1).
343        #[test]
344        fn n_q_minus_one_regular(n in 0u32..=4, q in 2u32..=5) {
345            let g = hamming(n, q, false).unwrap();
346            let expected_degree = (n as usize) * (q as usize - 1);
347            for v in 0..g.vcount() {
348                prop_assert_eq!(g.degree(v).unwrap(), expected_degree);
349            }
350        }
351
352        // Vertex count is q^n.
353        #[test]
354        fn vertex_count_is_q_to_the_n(n in 0u32..=5, q in 2u32..=4) {
355            let g = hamming(n, q, false).unwrap();
356            let expected = (q as u64).pow(n);
357            prop_assert_eq!(g.vcount() as u64, expected);
358        }
359
360        // Edge count is q^n * (q-1) * n / 2.
361        #[test]
362        fn edge_count_formula(n in 0u32..=4, q in 2u32..=5) {
363            let g = hamming(n, q, false).unwrap();
364            let vc = (q as u64).pow(n);
365            let expected = (vc * (q as u64 - 1) * (n as u64) / 2) as usize;
366            prop_assert_eq!(g.ecount(), expected);
367        }
368
369        // H(n, 2) ≡ Q_n: matches hypercube exactly.
370        #[test]
371        fn q2_equals_hypercube(n in 0u32..=6) {
372            use crate::hypercube;
373            let h = hamming(n, 2, false).unwrap();
374            let q = hypercube(n, false).unwrap();
375            prop_assert_eq!(h.vcount(), q.vcount());
376            prop_assert_eq!(h.ecount(), q.ecount());
377        }
378    }
379}