Skip to main content

rust_igraph/algorithms/layout/
umap.rs

1//! UMAP (Uniform Manifold Approximation and Projection) layout (ALGO-LO-013).
2//!
3//! Stochastic gradient descent layout that minimizes cross-entropy between
4//! high-dimensional and low-dimensional probability distributions. Supports
5//! distance-to-weight conversion, negative sampling, and both 2D and 3D.
6//!
7//! Reference: McInnes, Healy, Melville — UMAP: Uniform Manifold Approximation
8//! and Projection for Dimension Reduction (2020). arXiv:1802.03426.
9//! igraph C: `src/layout/umap.c`.
10
11use crate::core::{Graph, IgraphError, IgraphResult};
12
13const FORCE_LIMIT: f64 = 4.0;
14const MIN_DISTANCE_ATTRACTION: f64 = 0.0001;
15const CORRECT_DISTANCE_REPULSION: f64 = 0.01;
16
17/// Parameters for the UMAP layout algorithm.
18#[derive(Debug, Clone)]
19pub struct UmapParams {
20    /// Minimum distance parameter controlling embedding tightness.
21    /// Typical values: 0.0 to 1.0. Default: 0.01.
22    pub min_dist: f64,
23    /// Number of SGD epochs. Typical values: 30 to 500. Default: 500.
24    pub epochs: u32,
25    /// Whether `distances` are pre-computed weights (skip weight computation).
26    /// Default: false.
27    pub distances_are_weights: bool,
28}
29
30impl Default for UmapParams {
31    fn default() -> Self {
32        Self {
33            min_dist: 0.01,
34            epochs: 500,
35            distances_are_weights: false,
36        }
37    }
38}
39
40/// Compute UMAP weights from edge distances.
41///
42/// For each vertex, finds rho (minimum distance) and sigma (scale factor),
43/// then converts distances to exponentially decaying weights. Symmetrizes
44/// via fuzzy union: W = W1 + W2 - W1 * W2.
45///
46/// # Arguments
47///
48/// * `graph` — input graph (may be directed for kNN graphs).
49/// * `distances` — per-edge distances. If `None`, all edges get weight 1.
50///
51/// Returns a weight vector of length `ecount`.
52pub fn umap_compute_weights(graph: &Graph, distances: Option<&[f64]>) -> IgraphResult<Vec<f64>> {
53    let n = graph.vcount() as usize;
54    let m = graph.ecount();
55
56    if let Some(d) = distances {
57        if d.len() != m {
58            return Err(IgraphError::InvalidArgument(
59                "distances length must equal edge count".into(),
60            ));
61        }
62        if m > 0 {
63            let min_d = d.iter().copied().fold(f64::INFINITY, f64::min);
64            if min_d < 0.0 {
65                return Err(IgraphError::InvalidArgument(
66                    "distances must not be negative".into(),
67                ));
68            }
69            if min_d.is_nan() {
70                return Err(IgraphError::InvalidArgument(
71                    "distances must not be NaN".into(),
72                ));
73            }
74        }
75    }
76
77    // Build adjacency: for each vertex, list of (neighbor, edge_id)
78    let mut out_edges: Vec<Vec<(usize, usize)>> = vec![Vec::new(); n];
79    for eid in 0..m {
80        let (src, tgt) = graph.edge(eid as u32)?;
81        out_edges[src as usize].push((tgt as usize, eid));
82    }
83
84    // Per-vertex: compute weights and store in neighbors_seen / weights_seen
85    let mut neighbors_seen: Vec<Vec<usize>> = vec![Vec::new(); n];
86    let mut weights_seen: Vec<Vec<f64>> = vec![Vec::new(); n];
87
88    for i in 0..n {
89        let neis = &out_edges[i];
90        if neis.is_empty() {
91            continue;
92        }
93
94        // Find rho and dist_max
95        let (rho, dist_max) = if let Some(dists) = distances {
96            let mut rho = f64::INFINITY;
97            let mut dmax = f64::NEG_INFINITY;
98            for &(_nb, eid) in neis {
99                let d = dists[eid];
100                if d < rho {
101                    rho = d;
102                }
103                if d > dmax {
104                    dmax = d;
105                }
106            }
107            (rho, dmax)
108        } else {
109            (0.0, 0.0)
110        };
111
112        // Find sigma
113        let sigma = if dist_max == rho {
114            -1.0 // flag: all neighbors equidistant
115        } else {
116            let target = (neis.len() as f64).log2();
117            find_sigma(distances.unwrap(), neis, rho, target)
118        };
119
120        // Convert to weights
121        for &(nb, eid) in neis {
122            let weight = if sigma < 0.0 {
123                1.0
124            } else {
125                let d = distances.unwrap()[eid];
126                (-(d - rho) / sigma).exp()
127            };
128
129            // Check for self-loops
130            if nb == i {
131                return Err(IgraphError::InvalidArgument(
132                    "input graph must contain no self-loops".into(),
133                ));
134            }
135
136            neighbors_seen[i].push(nb);
137            weights_seen[i].push(weight);
138        }
139    }
140
141    // Symmetrize via fuzzy union
142    let mut result = vec![0.0_f64; m];
143
144    for eid in 0..m {
145        let (src, tgt) = graph.edge(eid as u32)?;
146        let i = src as usize;
147        let k = tgt as usize;
148
149        // Direct weight from i→k
150        let mut weight = 0.0_f64;
151        let mut found_idx = None;
152        for (l, &nb) in neighbors_seen[i].iter().enumerate() {
153            if nb == k {
154                weight = weights_seen[i][l];
155                found_idx = Some(l);
156                break;
157            }
158        }
159
160        // If tagged (-1), this edge was already unioned from the other direction
161        if weight < 0.0 {
162            result[eid] = 0.0;
163            continue;
164        }
165
166        // Tag so opposite won't double-count
167        if let Some(idx) = found_idx {
168            weights_seen[i][idx] = -1.0;
169        }
170
171        // Opposite weight from k→i
172        let mut weight_inv = 0.0_f64;
173        let mut found_inv_idx = None;
174        for (l, &nb) in neighbors_seen[k].iter().enumerate() {
175            if nb == i {
176                weight_inv = weights_seen[k][l];
177                found_inv_idx = Some(l);
178                break;
179            }
180        }
181
182        if weight_inv < 0.0 {
183            result[eid] = 0.0;
184            continue;
185        }
186
187        if let Some(idx) = found_inv_idx {
188            weights_seen[k][idx] = -1.0;
189        }
190
191        // Fuzzy union
192        result[eid] = weight + weight_inv - weight * weight_inv;
193    }
194
195    Ok(result)
196}
197
198/// Find sigma for a vertex by binary search.
199fn find_sigma(distances: &[f64], neis: &[(usize, usize)], rho: f64, target: f64) -> f64 {
200    let mut sigma = 1.0_f64;
201    let tol = 0.01;
202    let maxiter = 100;
203    let mut step = sigma;
204    let mut seen_max = false;
205
206    for iter in 0..maxiter {
207        let sum: f64 = neis
208            .iter()
209            .map(|&(_nb, eid)| (-(distances[eid] - rho) / sigma).exp())
210            .sum();
211
212        if sum < target {
213            if seen_max {
214                step /= 2.0;
215            } else if iter > 0 {
216                step *= 2.0;
217            }
218            sigma += step;
219        } else {
220            seen_max = true;
221            step /= 2.0;
222            sigma -= step;
223        }
224
225        if (sum - target).abs() < tol {
226            break;
227        }
228    }
229
230    sigma
231}
232
233/// Fit the a and b parameters using Gauss-Newton with line search.
234///
235/// These control the smooth probability function Q(d) = (1 + a*d^(2b))^-1
236/// in the embedding space.
237fn fit_ab(min_dist: f64) -> (f64, f64) {
238    let nr_points = 300;
239    let end_point = 3.0_f64;
240    let mut a = 1.8_f64;
241    let mut b = 0.8_f64;
242    let tol = 0.001;
243    let maxiter = 100;
244
245    // Distance lattice
246    let x: Vec<f64> = (0..nr_points)
247        .map(|i| (end_point / nr_points as f64) * i as f64 + 0.001)
248        .collect();
249
250    let mut residuals = vec![0.0_f64; nr_points];
251    let mut powb = vec![0.0_f64; nr_points];
252    let mut squared_sum_res;
253    let mut squared_sum_res_old = f64::INFINITY;
254
255    for iter in 0..maxiter {
256        // Compute residuals
257        squared_sum_res = 0.0;
258        for i in 0..nr_points {
259            powb[i] = x[i].powf(2.0 * b);
260            let q = 1.0 / (1.0 + a * powb[i]);
261            let p = if x[i] <= min_dist {
262                1.0
263            } else {
264                (-(x[i] - min_dist)).exp()
265            };
266            residuals[i] = q - p;
267            squared_sum_res += residuals[i] * residuals[i];
268        }
269
270        if squared_sum_res < tol * tol {
271            break;
272        }
273        if iter > 0 && (squared_sum_res_old.sqrt() - squared_sum_res.sqrt()).abs() < tol {
274            break;
275        }
276
277        // Jacobian
278        let mut j_a = vec![0.0_f64; nr_points];
279        let mut j_b = vec![0.0_f64; nr_points];
280        for i in 0..nr_points {
281            let tmp = 1.0 + a * powb[i];
282            j_a[i] = -2.0 * powb[i] / (tmp * tmp);
283            j_b[i] = j_a[i] * a * x[i].ln() * 2.0;
284        }
285
286        // J^T @ J (2x2) and J^T @ r (2x1)
287        let mut jtj = [[0.0_f64; 2]; 2];
288        let mut jtr = [0.0_f64; 2];
289        for i in 0..nr_points {
290            let jrow = [j_a[i], j_b[i]];
291            for j1 in 0..2 {
292                for j2 in 0..2 {
293                    jtj[j1][j2] += jrow[j1] * jrow[j2];
294                }
295                jtr[j1] += jrow[j1] * residuals[i];
296            }
297        }
298
299        // Solve 2x2 system via Cramer's rule
300        let det = jtj[0][0] * jtj[1][1] - jtj[0][1] * jtj[1][0];
301        if det.abs() < 1e-30 {
302            break;
303        }
304        let da = -(jtj[1][1] * jtr[0] - jtj[0][1] * jtr[1]) / det;
305        let db = -(jtj[0][0] * jtr[1] - jtj[1][0] * jtr[0]) / det;
306
307        // Line search
308        let mut da_step = da;
309        let mut db_step = db;
310        squared_sum_res_old = squared_sum_res;
311
312        let mut best_ssr = compute_ssr(&x, a + da_step, b + db_step, min_dist);
313
314        for _k in 0..30 {
315            da_step /= 2.0;
316            db_step /= 2.0;
317            let new_ssr = compute_ssr(&x, a + da_step, b + db_step, min_dist);
318            if new_ssr > best_ssr - tol {
319                da_step *= 2.0;
320                db_step *= 2.0;
321                break;
322            }
323            best_ssr = new_ssr;
324        }
325
326        a += da_step;
327        b += db_step;
328    }
329
330    (a, b)
331}
332
333fn compute_ssr(x: &[f64], a: f64, b: f64, min_dist: f64) -> f64 {
334    let mut ssr = 0.0;
335    for &xi in x {
336        let q = 1.0 / (1.0 + a * xi.powf(2.0 * b));
337        let p = if xi <= min_dist {
338            1.0
339        } else {
340            (-(xi - min_dist)).exp()
341        };
342        let r = q - p;
343        ssr += r * r;
344    }
345    ssr
346}
347
348fn clip_force(force: f64) -> f64 {
349    force.clamp(-FORCE_LIMIT, FORCE_LIMIT)
350}
351
352fn attract(dsq: f64, a: f64, b: f64) -> f64 {
353    -(2.0 * a * b * dsq.powf(b - 1.0)) / (1.0 + a * dsq.powf(b))
354}
355
356fn repel(dsq: f64, a: f64, b: f64) -> f64 {
357    let dsq_min = CORRECT_DISTANCE_REPULSION * CORRECT_DISTANCE_REPULSION;
358    (2.0 * b) / (dsq_min + dsq) / (1.0 + a * dsq.powf(b))
359}
360
361/// Compute the 2D UMAP layout.
362///
363/// Places vertices using stochastic gradient descent that minimizes
364/// cross-entropy between high-dimensional edge probabilities and
365/// low-dimensional distances.
366///
367/// # Arguments
368///
369/// * `graph` — input graph (treated as directed for kNN, undirected for general).
370/// * `seed` — optional initial positions `[x, y]` per vertex.
371/// * `distances` — optional per-edge distances. If `None`, all edges have weight 1.
372/// * `params` — algorithm parameters.
373///
374/// Returns `[x, y]` positions for each vertex.
375///
376/// # Errors
377///
378/// Returns error if distances length doesn't match edge count, distances are
379/// negative/NaN, or seed dimensions are wrong.
380///
381/// # Examples
382///
383/// ```
384/// use rust_igraph::{Graph, layout_umap, UmapParams};
385///
386/// let mut g = Graph::with_vertices(6);
387/// g.add_edge(0, 1).unwrap();
388/// g.add_edge(1, 2).unwrap();
389/// g.add_edge(2, 3).unwrap();
390/// g.add_edge(3, 4).unwrap();
391/// g.add_edge(4, 5).unwrap();
392///
393/// let params = UmapParams { epochs: 50, ..UmapParams::default() };
394/// let pos = layout_umap(&g, None, None, &params).unwrap();
395/// assert_eq!(pos.len(), 6);
396/// assert!(pos.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
397/// ```
398pub fn layout_umap(
399    graph: &Graph,
400    seed: Option<&[[f64; 2]]>,
401    distances: Option<&[f64]>,
402    params: &UmapParams,
403) -> IgraphResult<Vec<[f64; 2]>> {
404    let n = graph.vcount() as usize;
405
406    if params.min_dist < 0.0 {
407        return Err(IgraphError::InvalidArgument(
408            "min_dist must not be negative".into(),
409        ));
410    }
411
412    if n == 0 {
413        return Ok(Vec::new());
414    }
415    if n == 1 {
416        if let Some(s) = seed {
417            if s.len() != 1 {
418                return Err(IgraphError::InvalidArgument(
419                    "seed must have exactly vcount positions".into(),
420                ));
421            }
422            return Ok(s.to_vec());
423        }
424        return Ok(vec![[0.0, 0.0]]);
425    }
426
427    // Compute or use weights
428    let weights = if params.distances_are_weights {
429        distances
430            .ok_or_else(|| {
431                IgraphError::InvalidArgument(
432                    "distances_are_weights=true but no distances provided".into(),
433                )
434            })?
435            .to_vec()
436    } else {
437        umap_compute_weights(graph, distances)?
438    };
439
440    // Initial positions
441    let mut pos: Vec<[f64; 2]> = if let Some(s) = seed {
442        if s.len() != n {
443            return Err(IgraphError::InvalidArgument(format!(
444                "seed length {} != vcount {}",
445                s.len(),
446                n
447            )));
448        }
449        s.to_vec()
450    } else {
451        let mut rng = SplitMix64::new(42);
452        (0..n)
453            .map(|_| [rng.next_uniform(), rng.next_uniform()])
454            .collect()
455    };
456
457    // Fit a, b
458    let (a, b) = fit_ab(params.min_dist);
459
460    // Build edge list
461    let m = graph.ecount();
462    let mut edges: Vec<(usize, usize)> = Vec::with_capacity(m);
463    for eid in 0..m as u32 {
464        let (s, t) = graph.edge(eid)?;
465        edges.push((s as usize, t as usize));
466    }
467
468    // Build adjacency for neighbor avoidance (small graphs)
469    let avoid_neighbor_repulsion = n < 100;
470    let adj: Vec<Vec<usize>> = if avoid_neighbor_repulsion {
471        let mut a = vec![Vec::new(); n];
472        for &(s, t) in &edges {
473            a[s].push(t);
474            if s != t {
475                a[t].push(s);
476            }
477        }
478        a
479    } else {
480        Vec::new()
481    };
482
483    let negative_sampling_rate = 5usize.min(n - 1);
484
485    // Next epoch sample tracking
486    let mut next_epoch: Vec<f64> = vec![0.0; m];
487
488    let mut rng = SplitMix64::new(123);
489
490    // SGD epochs
491    for epoch in 0..params.epochs {
492        let learning_rate = 1.0 - (epoch as f64 + 1.0) / params.epochs as f64;
493
494        for eid in 0..m {
495            if weights[eid] <= 0.0 {
496                continue;
497            }
498            if next_epoch[eid] - epoch as f64 >= 1.0 {
499                continue;
500            }
501
502            next_epoch[eid] += 1.0 / weights[eid];
503
504            let (from_v, to_v) = edges[eid];
505
506            // Process both directions (swap from/to)
507            for swap in 0..2u8 {
508                let (from, to) = if swap == 0 {
509                    (from_v, to_v)
510                } else {
511                    (to_v, from_v)
512                };
513
514                // Attraction
515                let dx = pos[from][0] - pos[to][0];
516                let dy = pos[from][1] - pos[to][1];
517                let dsq = dx * dx + dy * dy;
518
519                if dsq >= MIN_DISTANCE_ATTRACTION * MIN_DISTANCE_ATTRACTION {
520                    let force = attract(dsq, a, b);
521                    let fx = clip_force(force * dx);
522                    let fy = clip_force(force * dy);
523                    pos[from][0] += learning_rate * fx;
524                    pos[from][1] += learning_rate * fy;
525                }
526
527                // Negative sampling (repulsion)
528                for _j in 0..negative_sampling_rate {
529                    let mut neg = rng.next_usize(n - 1);
530                    if neg >= from {
531                        neg += 1;
532                    }
533
534                    // Skip actual neighbors for small graphs
535                    if avoid_neighbor_repulsion && adj[from].contains(&neg) {
536                        continue;
537                    }
538
539                    let dx = pos[from][0] - pos[neg][0];
540                    let dy = pos[from][1] - pos[neg][1];
541                    let dsq = dx * dx + dy * dy;
542
543                    let force = repel(dsq, a, b);
544                    let fx = clip_force(force * dx);
545                    let fy = clip_force(force * dy);
546                    pos[from][0] += learning_rate * fx;
547                    pos[from][1] += learning_rate * fy;
548                }
549            }
550        }
551    }
552
553    // Center layout
554    let mut cx = 0.0_f64;
555    let mut cy = 0.0_f64;
556    for p in &pos {
557        cx += p[0];
558        cy += p[1];
559    }
560    cx /= n as f64;
561    cy /= n as f64;
562    for p in &mut pos {
563        p[0] -= cx;
564        p[1] -= cy;
565    }
566
567    Ok(pos)
568}
569
570/// Compute the 3D UMAP layout.
571///
572/// Same as [`layout_umap`] but produces 3D positions.
573///
574/// # Arguments
575///
576/// * `graph` — input graph.
577/// * `seed` — optional initial `[x, y, z]` positions.
578/// * `distances` — optional per-edge distances.
579/// * `params` — algorithm parameters.
580///
581/// Returns `[x, y, z]` positions for each vertex.
582pub fn layout_umap_3d(
583    graph: &Graph,
584    seed: Option<&[[f64; 3]]>,
585    distances: Option<&[f64]>,
586    params: &UmapParams,
587) -> IgraphResult<Vec<[f64; 3]>> {
588    let n = graph.vcount() as usize;
589
590    if params.min_dist < 0.0 {
591        return Err(IgraphError::InvalidArgument(
592            "min_dist must not be negative".into(),
593        ));
594    }
595
596    if n == 0 {
597        return Ok(Vec::new());
598    }
599    if n == 1 {
600        if let Some(s) = seed {
601            if s.len() != 1 {
602                return Err(IgraphError::InvalidArgument(
603                    "seed must have exactly vcount positions".into(),
604                ));
605            }
606            return Ok(s.to_vec());
607        }
608        return Ok(vec![[0.0, 0.0, 0.0]]);
609    }
610
611    let weights = if params.distances_are_weights {
612        distances
613            .ok_or_else(|| {
614                IgraphError::InvalidArgument(
615                    "distances_are_weights=true but no distances provided".into(),
616                )
617            })?
618            .to_vec()
619    } else {
620        umap_compute_weights(graph, distances)?
621    };
622
623    let mut pos: Vec<[f64; 3]> = if let Some(s) = seed {
624        if s.len() != n {
625            return Err(IgraphError::InvalidArgument(format!(
626                "seed length {} != vcount {}",
627                s.len(),
628                n
629            )));
630        }
631        s.to_vec()
632    } else {
633        let mut rng = SplitMix64::new(42);
634        (0..n)
635            .map(|_| [rng.next_uniform(), rng.next_uniform(), rng.next_uniform()])
636            .collect()
637    };
638
639    let (a, b) = fit_ab(params.min_dist);
640
641    let m = graph.ecount();
642    let mut edges: Vec<(usize, usize)> = Vec::with_capacity(m);
643    for eid in 0..m as u32 {
644        let (s, t) = graph.edge(eid)?;
645        edges.push((s as usize, t as usize));
646    }
647
648    let avoid_neighbor_repulsion = n < 100;
649    let adj: Vec<Vec<usize>> = if avoid_neighbor_repulsion {
650        let mut a = vec![Vec::new(); n];
651        for &(s, t) in &edges {
652            a[s].push(t);
653            if s != t {
654                a[t].push(s);
655            }
656        }
657        a
658    } else {
659        Vec::new()
660    };
661
662    let negative_sampling_rate = 5usize.min(n - 1);
663    let mut next_epoch: Vec<f64> = vec![0.0; m];
664    let mut rng = SplitMix64::new(123);
665
666    for epoch in 0..params.epochs {
667        let learning_rate = 1.0 - (epoch as f64 + 1.0) / params.epochs as f64;
668
669        for eid in 0..m {
670            if weights[eid] <= 0.0 {
671                continue;
672            }
673            if next_epoch[eid] - epoch as f64 >= 1.0 {
674                continue;
675            }
676            next_epoch[eid] += 1.0 / weights[eid];
677
678            let (from_v, to_v) = edges[eid];
679
680            for swap in 0..2u8 {
681                let (from, to) = if swap == 0 {
682                    (from_v, to_v)
683                } else {
684                    (to_v, from_v)
685                };
686
687                let dx = pos[from][0] - pos[to][0];
688                let dy = pos[from][1] - pos[to][1];
689                let dz = pos[from][2] - pos[to][2];
690                let dsq = dx * dx + dy * dy + dz * dz;
691
692                if dsq >= MIN_DISTANCE_ATTRACTION * MIN_DISTANCE_ATTRACTION {
693                    let force = attract(dsq, a, b);
694                    let fx = clip_force(force * dx);
695                    let fy = clip_force(force * dy);
696                    let fz = clip_force(force * dz);
697                    pos[from][0] += learning_rate * fx;
698                    pos[from][1] += learning_rate * fy;
699                    pos[from][2] += learning_rate * fz;
700                }
701
702                for _j in 0..negative_sampling_rate {
703                    let mut neg = rng.next_usize(n - 1);
704                    if neg >= from {
705                        neg += 1;
706                    }
707
708                    if avoid_neighbor_repulsion && adj[from].contains(&neg) {
709                        continue;
710                    }
711
712                    let dx = pos[from][0] - pos[neg][0];
713                    let dy = pos[from][1] - pos[neg][1];
714                    let dz = pos[from][2] - pos[neg][2];
715                    let dsq = dx * dx + dy * dy + dz * dz;
716
717                    let force = repel(dsq, a, b);
718                    let fx = clip_force(force * dx);
719                    let fy = clip_force(force * dy);
720                    let fz = clip_force(force * dz);
721                    pos[from][0] += learning_rate * fx;
722                    pos[from][1] += learning_rate * fy;
723                    pos[from][2] += learning_rate * fz;
724                }
725            }
726        }
727    }
728
729    // Center
730    let mut cx = 0.0_f64;
731    let mut cy = 0.0_f64;
732    let mut cz = 0.0_f64;
733    for p in &pos {
734        cx += p[0];
735        cy += p[1];
736        cz += p[2];
737    }
738    cx /= n as f64;
739    cy /= n as f64;
740    cz /= n as f64;
741    for p in &mut pos {
742        p[0] -= cx;
743        p[1] -= cy;
744        p[2] -= cz;
745    }
746
747    Ok(pos)
748}
749
750// ═══════════════════════════════════════════════════════════════════
751// Internal RNG
752// ═══════════════════════════════════════════════════════════════════
753
754struct SplitMix64 {
755    state: u64,
756}
757
758impl SplitMix64 {
759    fn new(seed: u64) -> Self {
760        Self { state: seed }
761    }
762
763    fn next_u64(&mut self) -> u64 {
764        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
765        let mut z = self.state;
766        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
767        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
768        z ^ (z >> 31)
769    }
770
771    fn next_uniform(&mut self) -> f64 {
772        (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
773    }
774
775    fn next_usize(&mut self, max: usize) -> usize {
776        (self.next_u64() as usize) % max
777    }
778}
779
780// ═══════════════════════════════════════════════════════════════════
781// Tests
782// ═══════════════════════════════════════════════════════════════════
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn test_umap_empty() {
790        let g = Graph::with_vertices(0);
791        let params = UmapParams::default();
792        let pos = layout_umap(&g, None, None, &params).unwrap();
793        assert!(pos.is_empty());
794    }
795
796    #[test]
797    fn test_umap_single() {
798        let g = Graph::with_vertices(1);
799        let params = UmapParams::default();
800        let pos = layout_umap(&g, None, None, &params).unwrap();
801        assert_eq!(pos.len(), 1);
802        assert!(pos[0][0].abs() < 1e-10 && pos[0][1].abs() < 1e-10);
803    }
804
805    #[test]
806    fn test_umap_path() {
807        let mut g = Graph::with_vertices(5);
808        for i in 0..4 {
809            g.add_edge(i, i + 1).unwrap();
810        }
811        let params = UmapParams {
812            epochs: 30,
813            ..UmapParams::default()
814        };
815        let pos = layout_umap(&g, None, None, &params).unwrap();
816        assert_eq!(pos.len(), 5);
817        for p in &pos {
818            assert!(p[0].is_finite() && p[1].is_finite());
819        }
820    }
821
822    #[test]
823    fn test_umap_cycle() {
824        let mut g = Graph::with_vertices(6);
825        for i in 0..6 {
826            g.add_edge(i, (i + 1) % 6).unwrap();
827        }
828        let params = UmapParams {
829            epochs: 30,
830            ..UmapParams::default()
831        };
832        let pos = layout_umap(&g, None, None, &params).unwrap();
833        assert_eq!(pos.len(), 6);
834        for p in &pos {
835            assert!(p[0].is_finite() && p[1].is_finite());
836        }
837    }
838
839    #[test]
840    fn test_umap_with_distances() {
841        let mut g = Graph::with_vertices(4);
842        g.add_edge(0, 1).unwrap();
843        g.add_edge(1, 2).unwrap();
844        g.add_edge(2, 3).unwrap();
845        let distances = vec![1.0, 2.0, 0.5];
846        let params = UmapParams {
847            epochs: 30,
848            ..UmapParams::default()
849        };
850        let pos = layout_umap(&g, None, Some(&distances), &params).unwrap();
851        assert_eq!(pos.len(), 4);
852        for p in &pos {
853            assert!(p[0].is_finite() && p[1].is_finite());
854        }
855    }
856
857    #[test]
858    fn test_umap_with_seed() {
859        let mut g = Graph::with_vertices(3);
860        g.add_edge(0, 1).unwrap();
861        g.add_edge(1, 2).unwrap();
862        let seed = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
863        let params = UmapParams {
864            epochs: 30,
865            ..UmapParams::default()
866        };
867        let pos = layout_umap(&g, Some(&seed), None, &params).unwrap();
868        assert_eq!(pos.len(), 3);
869        for p in &pos {
870            assert!(p[0].is_finite() && p[1].is_finite());
871        }
872    }
873
874    #[test]
875    fn test_umap_distances_are_weights() {
876        let mut g = Graph::with_vertices(4);
877        g.add_edge(0, 1).unwrap();
878        g.add_edge(1, 2).unwrap();
879        g.add_edge(2, 3).unwrap();
880        let weights = vec![0.9, 0.5, 0.8];
881        let params = UmapParams {
882            epochs: 30,
883            distances_are_weights: true,
884            ..UmapParams::default()
885        };
886        let pos = layout_umap(&g, None, Some(&weights), &params).unwrap();
887        assert_eq!(pos.len(), 4);
888        for p in &pos {
889            assert!(p[0].is_finite() && p[1].is_finite());
890        }
891    }
892
893    #[test]
894    fn test_umap_3d() {
895        let mut g = Graph::with_vertices(5);
896        for i in 0..4 {
897            g.add_edge(i, i + 1).unwrap();
898        }
899        let params = UmapParams {
900            epochs: 30,
901            ..UmapParams::default()
902        };
903        let pos = layout_umap_3d(&g, None, None, &params).unwrap();
904        assert_eq!(pos.len(), 5);
905        for p in &pos {
906            assert!(p[0].is_finite() && p[1].is_finite() && p[2].is_finite());
907        }
908    }
909
910    #[test]
911    fn test_umap_negative_distances_error() {
912        let mut g = Graph::with_vertices(3);
913        g.add_edge(0, 1).unwrap();
914        g.add_edge(1, 2).unwrap();
915        let distances = vec![1.0, -0.5];
916        let params = UmapParams::default();
917        assert!(layout_umap(&g, None, Some(&distances), &params).is_err());
918    }
919
920    #[test]
921    fn test_umap_wrong_distances_length() {
922        let mut g = Graph::with_vertices(3);
923        g.add_edge(0, 1).unwrap();
924        g.add_edge(1, 2).unwrap();
925        let distances = vec![1.0]; // should be 2
926        let params = UmapParams::default();
927        assert!(layout_umap(&g, None, Some(&distances), &params).is_err());
928    }
929
930    #[test]
931    fn test_umap_wrong_seed_length() {
932        let mut g = Graph::with_vertices(3);
933        g.add_edge(0, 1).unwrap();
934        g.add_edge(1, 2).unwrap();
935        let seed = vec![[0.0, 0.0], [1.0, 0.0]]; // should be 3
936        let params = UmapParams {
937            epochs: 10,
938            ..UmapParams::default()
939        };
940        assert!(layout_umap(&g, Some(&seed), None, &params).is_err());
941    }
942
943    #[test]
944    fn test_umap_complete() {
945        let mut g = Graph::with_vertices(5);
946        for i in 0..5u32 {
947            for j in (i + 1)..5 {
948                g.add_edge(i, j).unwrap();
949            }
950        }
951        let params = UmapParams {
952            epochs: 30,
953            ..UmapParams::default()
954        };
955        let pos = layout_umap(&g, None, None, &params).unwrap();
956        assert_eq!(pos.len(), 5);
957        for p in &pos {
958            assert!(p[0].is_finite() && p[1].is_finite());
959        }
960    }
961
962    #[test]
963    fn test_umap_deterministic() {
964        let mut g = Graph::with_vertices(4);
965        g.add_edge(0, 1).unwrap();
966        g.add_edge(1, 2).unwrap();
967        g.add_edge(2, 3).unwrap();
968        g.add_edge(3, 0).unwrap();
969        let params = UmapParams {
970            epochs: 30,
971            ..UmapParams::default()
972        };
973        let pos1 = layout_umap(&g, None, None, &params).unwrap();
974        let pos2 = layout_umap(&g, None, None, &params).unwrap();
975        for i in 0..4 {
976            assert!((pos1[i][0] - pos2[i][0]).abs() < 1e-10);
977            assert!((pos1[i][1] - pos2[i][1]).abs() < 1e-10);
978        }
979    }
980
981    #[test]
982    fn test_compute_weights_basic() {
983        let mut g = Graph::with_vertices(4);
984        g.add_edge(0, 1).unwrap();
985        g.add_edge(1, 2).unwrap();
986        g.add_edge(2, 3).unwrap();
987        let distances = vec![1.0, 2.0, 0.5];
988        let w = umap_compute_weights(&g, Some(&distances)).unwrap();
989        assert_eq!(w.len(), 3);
990        for &wi in &w {
991            assert!((0.0..=1.0).contains(&wi));
992        }
993    }
994
995    #[test]
996    fn test_compute_weights_no_distances() {
997        let mut g = Graph::with_vertices(3);
998        g.add_edge(0, 1).unwrap();
999        g.add_edge(1, 2).unwrap();
1000        let w = umap_compute_weights(&g, None).unwrap();
1001        assert_eq!(w.len(), 2);
1002        // Without distances, all weights should be 1.0
1003        for &wi in &w {
1004            assert!((wi - 1.0).abs() < 1e-10);
1005        }
1006    }
1007
1008    #[test]
1009    fn test_fit_ab() {
1010        let (a, b) = fit_ab(0.1);
1011        assert!(a > 0.0 && a < 100.0);
1012        assert!(b > 0.0 && b < 5.0);
1013    }
1014}