Skip to main content

rust_igraph/algorithms/paths/
random_walk.rs

1//! Random walk on a graph (ALGO-TR-003).
2//!
3//! Counterpart of `igraph_random_walk()` from
4//! `references/igraph/src/paths/random_walk.c:288`. Performs a
5//! discrete-time random walk starting at `start` for at most `steps`
6//! transitions. At each vertex the next neighbour is chosen
7//! - uniformly at random when `weights == None`,
8//! - with probability proportional to edge weight when `weights ==
9//!   Some(_)`.
10//!
11//! If the walk reaches a vertex with no outgoing neighbours under the
12//! chosen `mode`, the walk stops early (matches upstream's
13//! `IGRAPH_RANDOM_WALK_STUCK_RETURN` variant). The returned vertex
14//! chain always starts with `start`; the parallel edge chain has
15//! length `vertices.len() - 1`.
16//!
17//! For deterministic reproducibility the API takes a `seed: u64`
18//! parameter. We seed an inline `SplitMix64` PRNG — no external
19//! `rand` dependency. Callers wanting non-deterministic behaviour can
20//! pass `seed = std::time::SystemTime::now()`-derived bytes.
21
22use crate::algorithms::paths::dijkstra::DijkstraMode;
23use crate::core::graph::EdgeId;
24use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
25
26/// Minimal `SplitMix64` PRNG. Stateless across the public API but
27/// internally tracks one `u64`. Used solely to keep this AWU
28/// dependency-free; not exposed.
29struct SplitMix64(u64);
30
31impl SplitMix64 {
32    fn new(seed: u64) -> Self {
33        Self(seed)
34    }
35    fn next_u64(&mut self) -> u64 {
36        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
37        let mut z = self.0;
38        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
39        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
40        z ^ (z >> 31)
41    }
42    /// Uniform integer in `[0, bound)`. `bound > 0`.
43    fn gen_index(&mut self, bound: usize) -> usize {
44        // Standard rejection-free modulo for our scope; bound stays
45        // well under 2^32 in any realistic graph.
46        let r = self.next_u64() % (bound as u64);
47        usize::try_from(r).expect("bound fits in usize by construction")
48    }
49    /// Uniform float in `[0, 1)`. Uses 53 mantissa bits.
50    fn gen_unit(&mut self) -> f64 {
51        // Take the 53 high bits of the PRNG output and scale into [0,1).
52        let bits = self.next_u64() >> 11; // now in [0, 2^53)
53        // Both `bits` (in [0, 2^53)) and `2^53` are exactly representable
54        // as f64; the divisor is a hex float literal to keep clippy happy.
55        #[allow(clippy::cast_precision_loss)]
56        let numerator = bits as f64;
57        numerator * (1.0_f64 / 9_007_199_254_740_992.0_f64)
58    }
59}
60
61fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
62    let Some(w) = weights else {
63        return Ok(());
64    };
65    let m = graph.ecount();
66    if w.len() != m {
67        return Err(IgraphError::InvalidArgument(format!(
68            "weights vector size ({}) differs from edge count ({})",
69            w.len(),
70            m
71        )));
72    }
73    for (e, &v) in w.iter().enumerate() {
74        if v.is_nan() {
75            return Err(IgraphError::InvalidArgument(format!(
76                "weight at edge {e} is NaN"
77            )));
78        }
79        if v < 0.0 {
80            return Err(IgraphError::InvalidArgument(format!(
81                "weight at edge {e} is negative ({v}); random walk weights must be non-negative"
82            )));
83        }
84    }
85    Ok(())
86}
87
88fn incident_for_mode(graph: &Graph, v: VertexId, mode: DijkstraMode) -> IgraphResult<Vec<EdgeId>> {
89    if !graph.is_directed() {
90        return graph.incident(v);
91    }
92    match mode {
93        DijkstraMode::Out => graph.incident(v),
94        DijkstraMode::In => graph.incident_in(v),
95        DijkstraMode::All => {
96            let mut out = graph.incident(v)?;
97            out.extend(graph.incident_in(v)?);
98            Ok(out)
99        }
100    }
101}
102
103/// Random walk on `graph` starting at `start`, taking up to `steps`
104/// transitions.
105///
106/// `weights = None` selects neighbours uniformly. `weights = Some(_)`
107/// selects each candidate edge with probability proportional to its
108/// weight; a candidate edge with weight `0.0` is never chosen, but at
109/// least one outgoing edge of every visited vertex must have positive
110/// weight or the walk gets stuck. Negative or NaN weights return
111/// [`IgraphError::InvalidArgument`].
112///
113/// Returns `(vertex_chain, edge_chain)`. `vertex_chain[0] == start`;
114/// when the walk completes all `steps` transitions, the chain has
115/// length `steps + 1` and `edge_chain` has length `steps`. If the walk
116/// gets stuck early (no admissible outgoing edges), the result is
117/// truncated to the actual prefix (matches upstream's
118/// `IGRAPH_RANDOM_WALK_STUCK_RETURN`).
119///
120/// `seed` deterministically initialises the internal `SplitMix64`
121/// PRNG — same `(graph, start, mode, steps, weights, seed)` always
122/// produces the same chain.
123///
124/// Counterpart of `igraph_random_walk(_, &weights, _, _, start, mode,
125/// steps, IGRAPH_RANDOM_WALK_STUCK_RETURN)`.
126///
127/// # Examples
128///
129/// ```
130/// use rust_igraph::{Graph, random_walk, DijkstraMode};
131///
132/// // 4-cycle 0-1-2-3-0: every vertex has two neighbours so the walk
133/// // never gets stuck.
134/// let mut g = Graph::with_vertices(4);
135/// g.add_edge(0, 1).unwrap();
136/// g.add_edge(1, 2).unwrap();
137/// g.add_edge(2, 3).unwrap();
138/// g.add_edge(3, 0).unwrap();
139/// let (vs, es) = random_walk(&g, None, 0, DijkstraMode::Out, 5, 42).unwrap();
140/// assert_eq!(vs.len(), 6);
141/// assert_eq!(es.len(), 5);
142/// assert_eq!(vs[0], 0);
143/// ```
144pub fn random_walk(
145    graph: &Graph,
146    weights: Option<&[f64]>,
147    start: VertexId,
148    mode: DijkstraMode,
149    steps: u32,
150    seed: u64,
151) -> IgraphResult<(Vec<VertexId>, Vec<EdgeId>)> {
152    let n = graph.vcount();
153    if start >= n {
154        return Err(IgraphError::VertexOutOfRange { id: start, n });
155    }
156    validate_weights(graph, weights)?;
157
158    let mut rng = SplitMix64::new(seed);
159    let mut vs: Vec<VertexId> = Vec::with_capacity(steps as usize + 1);
160    let mut es: Vec<EdgeId> = Vec::with_capacity(steps as usize);
161    vs.push(start);
162
163    let mut current = start;
164    for _ in 0..steps {
165        let incidents = incident_for_mode(graph, current, mode)?;
166        if incidents.is_empty() {
167            break;
168        }
169
170        let next_eid = match weights {
171            None => {
172                let idx = rng.gen_index(incidents.len());
173                Some(incidents[idx])
174            }
175            Some(ws) => {
176                // Sum admissible weights (skip non-finite). If sum is 0,
177                // there is no admissible outgoing edge and the walk
178                // stops early.
179                let mut total = 0.0_f64;
180                for &eid in &incidents {
181                    let w = ws[eid as usize];
182                    if w.is_finite() && w > 0.0 {
183                        total += w;
184                    }
185                }
186                if total <= 0.0 {
187                    None
188                } else {
189                    let target = rng.gen_unit() * total;
190                    let mut acc = 0.0_f64;
191                    let mut chosen: Option<EdgeId> = None;
192                    for &eid in &incidents {
193                        let w = ws[eid as usize];
194                        if !(w.is_finite() && w > 0.0) {
195                            continue;
196                        }
197                        acc += w;
198                        if acc >= target {
199                            chosen = Some(eid);
200                            break;
201                        }
202                    }
203                    // Floating-point edge case: if rounding put `acc` a
204                    // hair below `target`, fall back to the last
205                    // positive-weight edge.
206                    if chosen.is_none() {
207                        for &eid in incidents.iter().rev() {
208                            let w = ws[eid as usize];
209                            if w.is_finite() && w > 0.0 {
210                                chosen = Some(eid);
211                                break;
212                            }
213                        }
214                    }
215                    chosen
216                }
217            }
218        };
219
220        let Some(eid) = next_eid else {
221            break;
222        };
223        let next_vertex = graph.edge_other(eid, current)?;
224        es.push(eid);
225        vs.push(next_vertex);
226        current = next_vertex;
227    }
228
229    Ok((vs, es))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn unit_weights_4_cycle_walk_length() {
238        let mut g = Graph::with_vertices(4);
239        g.add_edge(0, 1).unwrap();
240        g.add_edge(1, 2).unwrap();
241        g.add_edge(2, 3).unwrap();
242        g.add_edge(3, 0).unwrap();
243        let (vs, es) = random_walk(&g, None, 0, DijkstraMode::Out, 5, 42).unwrap();
244        assert_eq!(vs.len(), 6);
245        assert_eq!(es.len(), 5);
246        assert_eq!(vs[0], 0);
247        // Every step is along an edge: consecutive vertices are
248        // adjacent in the input graph.
249        for w in vs.windows(2) {
250            let (a, b) = (w[0], w[1]);
251            assert!(
252                (g.find_eid(a, b).unwrap()).is_some() || (g.find_eid(b, a).unwrap()).is_some(),
253                "non-adjacent step {a}→{b}"
254            );
255        }
256    }
257
258    #[test]
259    fn stuck_returns_shorter_walk() {
260        // Sink vertex 1 has no outgoing edges in OUT mode.
261        let mut g = Graph::new(3, true).unwrap();
262        g.add_edge(0, 1).unwrap();
263        g.add_edge(2, 1).unwrap();
264        let (vs, es) = random_walk(&g, None, 0, DijkstraMode::Out, 10, 7).unwrap();
265        // 0→1 is the only step; sink 1 has no out-edges → stops.
266        assert_eq!(vs, vec![0, 1]);
267        assert_eq!(es.len(), 1);
268    }
269
270    #[test]
271    fn zero_steps_returns_singleton() {
272        let mut g = Graph::with_vertices(3);
273        g.add_edge(0, 1).unwrap();
274        let (vs, es) = random_walk(&g, None, 1, DijkstraMode::Out, 0, 0).unwrap();
275        assert_eq!(vs, vec![1]);
276        assert!(es.is_empty());
277    }
278
279    #[test]
280    fn isolated_vertex_stuck_immediately() {
281        let g = Graph::with_vertices(3);
282        let (vs, es) = random_walk(&g, None, 1, DijkstraMode::Out, 5, 0).unwrap();
283        assert_eq!(vs, vec![1]);
284        assert!(es.is_empty());
285    }
286
287    #[test]
288    fn deterministic_with_same_seed() {
289        let mut g = Graph::with_vertices(5);
290        for i in 0..5u32 {
291            g.add_edge(i, (i + 1) % 5).unwrap();
292            g.add_edge(i, (i + 2) % 5).unwrap();
293        }
294        let r1 = random_walk(&g, None, 0, DijkstraMode::Out, 20, 12345).unwrap();
295        let r2 = random_walk(&g, None, 0, DijkstraMode::Out, 20, 12345).unwrap();
296        assert_eq!(r1, r2);
297    }
298
299    #[test]
300    fn different_seeds_yield_different_walks() {
301        let mut g = Graph::with_vertices(5);
302        for i in 0..5u32 {
303            g.add_edge(i, (i + 1) % 5).unwrap();
304            g.add_edge(i, (i + 2) % 5).unwrap();
305        }
306        let r1 = random_walk(&g, None, 0, DijkstraMode::Out, 20, 1).unwrap();
307        let r2 = random_walk(&g, None, 0, DijkstraMode::Out, 20, 2).unwrap();
308        // Probabilistically these almost surely differ; deterministic
309        // because the PRNG is seeded.
310        assert_ne!(r1, r2);
311    }
312
313    #[test]
314    fn weighted_walk_picks_only_positive_weight_edges() {
315        // 0-1 weight 1, 0-2 weight 0: 0→2 must never be chosen.
316        let mut g = Graph::with_vertices(3);
317        g.add_edge(0, 1).unwrap();
318        g.add_edge(0, 2).unwrap();
319        let weights = [1.0_f64, 0.0];
320        // Need a return path from 1; add 1-0 reverse via the same edge
321        // (undirected, so 0-1 ≡ 1-0).
322        // Run a couple of one-step walks; every one must end at 1.
323        for seed in 0..16u64 {
324            let (vs, _) = random_walk(&g, Some(&weights), 0, DijkstraMode::Out, 1, seed).unwrap();
325            assert_eq!(vs, vec![0, 1], "seed {seed}");
326        }
327    }
328
329    #[test]
330    fn weighted_zero_total_weight_stops_early() {
331        // All outgoing edges have weight 0 → no admissible edge.
332        let mut g = Graph::with_vertices(3);
333        g.add_edge(0, 1).unwrap();
334        g.add_edge(0, 2).unwrap();
335        let weights = [0.0_f64, 0.0];
336        let (vs, es) = random_walk(&g, Some(&weights), 0, DijkstraMode::Out, 5, 0).unwrap();
337        assert_eq!(vs, vec![0]);
338        assert!(es.is_empty());
339    }
340
341    #[test]
342    fn negative_weight_errors() {
343        let mut g = Graph::with_vertices(2);
344        g.add_edge(0, 1).unwrap();
345        let weights = [-1.0_f64];
346        assert!(random_walk(&g, Some(&weights), 0, DijkstraMode::Out, 1, 0).is_err());
347    }
348
349    #[test]
350    fn nan_weight_errors() {
351        let mut g = Graph::with_vertices(2);
352        g.add_edge(0, 1).unwrap();
353        let weights = [f64::NAN];
354        assert!(random_walk(&g, Some(&weights), 0, DijkstraMode::Out, 1, 0).is_err());
355    }
356
357    #[test]
358    fn out_of_range_start_errors() {
359        let g = Graph::with_vertices(3);
360        assert!(random_walk(&g, None, 99, DijkstraMode::Out, 1, 0).is_err());
361    }
362
363    #[test]
364    fn weights_size_mismatch_errors() {
365        let mut g = Graph::with_vertices(2);
366        g.add_edge(0, 1).unwrap();
367        let weights = [1.0_f64, 2.0];
368        assert!(random_walk(&g, Some(&weights), 0, DijkstraMode::Out, 1, 0).is_err());
369    }
370
371    #[test]
372    fn directed_in_mode_walks_reverse_edges() {
373        let mut g = Graph::new(3, true).unwrap();
374        g.add_edge(0, 1).unwrap();
375        g.add_edge(1, 2).unwrap();
376        // From sink 2 with IN-mode: reaches 1 in step 1, 0 in step 2.
377        let (vs, _) = random_walk(&g, None, 2, DijkstraMode::In, 2, 0).unwrap();
378        assert_eq!(vs, vec![2, 1, 0]);
379    }
380}