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