use rayon::prelude::*;
use crate::prelude::*;
use bixverse_core::core::math::vector_helpers::pearson_correlation;
use bixverse_methods::graph::graph_components::connected_components;
use bixverse_methods::graph::shortest_paths::{dijkstra_from_source, multi_source_dijkstra};
const CELL_BLOCK: usize = 4096;
const SILVERMAN_FACTOR: f64 = 1.06;
const SILVERMAN_EXPONENT: f64 = -0.2;
const PSEUDOTIME_CONVERGENCE_CORR: f64 = 0.9999;
const MIN_MAX_ITERATIONS: usize = 2;
pub struct PseudotimeResult {
pub pseudotime: Vec<f32>,
pub weights: Vec<f32>,
pub n_waypoints: usize,
pub n_cells: usize,
pub iterations: usize,
pub converged: bool,
}
pub fn build_symmetric_knn_graph(
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
) -> Result<CompressedSparseData2<f32>, ScErrors> {
let n = knn_indices.len();
if knn_distances.len() != n {
return Err(CoreErrors::DimensionMisMatchSparse {
indices_len: n,
data_len: knn_distances.len(),
}
.into());
}
let mut edges: Vec<(u32, u32, f32)> =
Vec::with_capacity(n * knn_indices.first().map_or(1, Vec::len).max(1));
for (i, neighbours) in knn_indices.iter().enumerate() {
if neighbours.len() != knn_distances[i].len() {
return Err(CoreErrors::DimensionMisMatchSparse {
indices_len: neighbours.len(),
data_len: knn_distances[i].len(),
}
.into());
}
for (slot, &j) in neighbours.iter().enumerate() {
if j == i {
continue;
}
if j >= n {
return Err(CoreErrors::SliceIndexOutOfBounds { index: j, len: n }.into());
}
let d = knn_distances[i][slot];
let (lo, hi) = if i < j { (i, j) } else { (j, i) };
edges.push((lo as u32, hi as u32, d));
}
}
edges.par_sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)).then(a.2.total_cmp(&b.2)));
edges.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
Ok(edges_to_csr(n, &edges))
}
fn edges_to_csr(n: usize, edges: &[(u32, u32, f32)]) -> CompressedSparseData2<f32> {
let mut degree = vec![0u32; n];
for &(a, b, _) in edges {
degree[a as usize] += 1;
degree[b as usize] += 1;
}
let mut indptr = vec![0u32; n + 1];
for i in 0..n {
indptr[i + 1] = indptr[i] + degree[i];
}
let nnz = indptr[n] as usize;
let mut indices = vec![0u32; nnz];
let mut data = vec![0.0f32; nnz];
let mut cursor: Vec<u32> = indptr[..n].to_vec();
for &(a, b, w) in edges {
let pb = cursor[b as usize] as usize;
indices[pb] = a;
data[pb] = w;
cursor[b as usize] += 1;
}
for &(a, b, w) in edges {
let pa = cursor[a as usize] as usize;
indices[pa] = b;
data[pa] = w;
cursor[a as usize] += 1;
}
CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
}
pub fn connect_graph(
graph: &CompressedSparseData2<f32>,
data: &[Vec<f32>],
start_cell: usize,
) -> Result<(CompressedSparseData2<f32>, usize), ScErrors> {
let n = graph.shape.0;
if start_cell >= n {
return Err(CoreErrors::SliceIndexOutOfBounds {
index: start_cell,
len: n,
}
.into());
}
if data.len() != n {
return Err(CoreErrors::DimensionMisMatchSparse {
indices_len: data.len(),
data_len: n,
}
.into());
}
let (n_components, _) = connected_components(graph)?;
if n_components <= 1 {
return Ok((graph.clone(), 0));
}
let mut extra: Vec<(u32, u32, f32)> = Vec::new();
let mut current = graph.clone();
let mut dist = vec![0.0f64; n];
for _ in 0..n {
dijkstra_from_source(¤t, start_cell, &mut dist)?;
let unreachable: Vec<usize> = (0..n).filter(|&i| dist[i].is_infinite()).collect();
if unreachable.is_empty() {
return Ok((current, extra.len()));
}
let mut anchor = start_cell;
let mut best = f64::NEG_INFINITY;
for (i, &d) in dist.iter().enumerate() {
if d.is_finite() && d > best {
best = d;
anchor = i;
}
}
let (target, weight) = unreachable
.par_iter()
.map(|&u| {
let d: f32 = data[u]
.iter()
.zip(data[anchor].iter())
.map(|(&a, &b)| (a - b) * (a - b))
.sum();
(u, d.sqrt())
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.ok_or(ScErrors::PalantirDisconnectedGraph {
n_unreachable: unreachable.len(),
repairs: extra.len(),
})?;
let (lo, hi) = if anchor < target {
(anchor as u32, target as u32)
} else {
(target as u32, anchor as u32)
};
extra.push((lo, hi, weight));
current = rebuild_with_extra(graph, &extra);
}
dijkstra_from_source(¤t, start_cell, &mut dist)?;
Err(ScErrors::PalantirDisconnectedGraph {
n_unreachable: dist.iter().filter(|d| d.is_infinite()).count(),
repairs: extra.len(),
})
}
fn rebuild_with_extra(
graph: &CompressedSparseData2<f32>,
extra: &[(u32, u32, f32)],
) -> CompressedSparseData2<f32> {
let n = graph.shape.0;
let mut edges: Vec<(u32, u32, f32)> = Vec::with_capacity(graph.get_nnz() / 2 + extra.len());
for i in 0..n {
for idx in graph.indptr[i] as usize..graph.indptr[i + 1] as usize {
let j = graph.indices[idx];
if (i as u32) < j {
edges.push((i as u32, j, graph.data[idx]));
}
}
}
edges.extend_from_slice(extra);
edges.par_sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)).then(a.2.total_cmp(&b.2)));
edges.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
edges_to_csr(n, &edges)
}
pub fn compute_pseudotime(
graph: &CompressedSparseData2<f32>,
waypoints: &[usize],
start_cell: usize,
max_iterations: usize,
verbosity: Verbosity,
) -> Result<PseudotimeResult, ScErrors> {
let n_cells = graph.shape.0;
let n_wp = waypoints.len();
if n_wp == 0 {
return Err(CoreErrors::InvalidArgument(
"Palantir: no waypoints were supplied".to_string(),
)
.into());
}
if waypoints[0] != start_cell {
return Err(ScErrors::PalantirStartCellNotFirstWaypoint {
start_cell,
first_waypoint: waypoints[0],
});
}
if max_iterations < MIN_MAX_ITERATIONS {
return Err(ScErrors::PalantirMaxIterationsTooSmall {
max_iterations,
minimum: MIN_MAX_ITERATIONS,
});
}
if verbosity.normal_verbosity() {
println!("Computing geodesic distances from {n_wp} waypoints...");
}
let geodesic = multi_source_dijkstra(graph, waypoints)?;
let n_unreachable = count_unreachable_cells(&geodesic, n_wp, n_cells);
if n_unreachable > 0 {
return Err(ScErrors::PalantirUnreachableFromWaypoints { n_unreachable });
}
let weights = gaussian_waypoint_weights(&geodesic, n_wp, n_cells)?;
if verbosity.normal_verbosity() {
println!("Refining pseudotime (cap {max_iterations} iterations)...");
}
let mut pseudotime: Vec<f64> = geodesic[..n_cells].to_vec();
let mut next = vec![0.0f64; n_cells];
let mut iterations = 0usize;
let mut converged = false;
for iteration in 1..max_iterations {
refine_once(
&geodesic,
&weights,
&pseudotime,
waypoints,
n_cells,
&mut next,
);
let corr = pearson_correlation(&pseudotime, &next);
iterations = iteration;
std::mem::swap(&mut pseudotime, &mut next);
if let Some(corr) = corr {
if verbosity.detailed_verbosity() {
println!(" correlation at iteration {iteration}: {corr:.6}");
}
if corr > PSEUDOTIME_CONVERGENCE_CORR {
converged = true;
break;
}
}
}
let pseudotime = normalise_pseudotime(pseudotime)?;
Ok(PseudotimeResult {
pseudotime,
weights,
n_waypoints: n_wp,
n_cells,
iterations,
converged,
})
}
fn count_unreachable_cells(geodesic: &[f64], n_wp: usize, n_cells: usize) -> usize {
let mut bad = vec![false; n_cells];
bad.par_chunks_mut(CELL_BLOCK)
.enumerate()
.for_each(|(block, out)| {
let c0 = block * CELL_BLOCK;
let len = out.len();
for wp in 0..n_wp {
let base = wp * n_cells + c0;
let row = &geodesic[base..base + len];
for k in 0..len {
out[k] |= !row[k].is_finite();
}
}
});
bad.par_iter().filter(|b| **b).count()
}
fn population_standard_deviation(x: &[f64]) -> f64 {
if x.is_empty() {
return 0.0;
}
let n = x.len() as f64;
let mean = x.par_iter().sum::<f64>() / n;
let variance = x.par_iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
variance.sqrt()
}
fn gaussian_waypoint_weights(
geodesic: &[f64],
n_wp: usize,
n_cells: usize,
) -> Result<Vec<f32>, ScErrors> {
let sdv = population_standard_deviation(geodesic)
* SILVERMAN_FACTOR
* (n_wp as f64 * n_cells as f64).powf(SILVERMAN_EXPONENT);
if !sdv.is_finite() || sdv <= 0.0 {
return Err(ScErrors::PalantirDegeneratePseudotime {
reason: "the Silverman bandwidth is zero or non-finite",
});
}
let inv_two_var = 0.5 / (sdv * sdv);
let mut col_min = vec![f64::INFINITY; n_cells];
col_min
.par_chunks_mut(CELL_BLOCK)
.enumerate()
.for_each(|(block, out)| {
let c0 = block * CELL_BLOCK;
let len = out.len();
for wp in 0..n_wp {
let base = wp * n_cells + c0;
let row = &geodesic[base..base + len];
for k in 0..len {
if row[k] < out[k] {
out[k] = row[k];
}
}
}
});
let mut weights = vec![0.0f32; n_wp * n_cells];
weights
.par_chunks_mut(n_cells)
.enumerate()
.for_each(|(wp, row)| {
let src = &geodesic[wp * n_cells..(wp + 1) * n_cells];
for c in 0..n_cells {
let d = src[c];
let m = col_min[c];
row[c] = (-(d * d - m * m) * inv_two_var).exp() as f32;
}
});
let mut col_sum = vec![0.0f64; n_cells];
col_sum
.par_chunks_mut(CELL_BLOCK)
.enumerate()
.for_each(|(block, out)| {
let c0 = block * CELL_BLOCK;
let len = out.len();
for wp in 0..n_wp {
let base = wp * n_cells + c0;
let row = &weights[base..base + len];
for k in 0..len {
out[k] += row[k] as f64;
}
}
});
if col_sum.par_iter().any(|s| !s.is_finite() || *s <= 0.0) {
return Err(ScErrors::PalantirDegeneratePseudotime {
reason: "a cell received zero total waypoint weight",
});
}
weights.par_chunks_mut(n_cells).for_each(|row| {
for (c, w) in row.iter_mut().enumerate() {
*w = (*w as f64 / col_sum[c]) as f32;
}
});
Ok(weights)
}
fn refine_once(
geodesic: &[f64],
weights: &[f32],
pseudotime: &[f64],
waypoints: &[usize],
n_cells: usize,
out: &mut [f64],
) {
let n_wp = waypoints.len();
let t_wp: Vec<f64> = waypoints.iter().map(|&w| pseudotime[w]).collect();
out.par_chunks_mut(CELL_BLOCK)
.enumerate()
.for_each(|(block, out)| {
let c0 = block * CELL_BLOCK;
let len = out.len();
let d_row = &geodesic[c0..c0 + len];
let w_row = &weights[c0..c0 + len];
for k in 0..len {
out[k] = d_row[k] * w_row[k] as f64;
}
for wp in 1..n_wp {
let base = wp * n_cells + c0;
let d_row = &geodesic[base..base + len];
let w_row = &weights[base..base + len];
let t = t_wp[wp];
for k in 0..len {
let sign = if pseudotime[c0 + k] < t { -1.0 } else { 1.0 };
out[k] += (d_row[k] * sign + t) * w_row[k] as f64;
}
}
});
}
fn normalise_pseudotime(pseudotime: Vec<f64>) -> Result<Vec<f32>, ScErrors> {
let lo = pseudotime
.par_iter()
.copied()
.reduce(|| f64::INFINITY, f64::min);
let hi = pseudotime
.par_iter()
.copied()
.reduce(|| f64::NEG_INFINITY, f64::max);
let range = hi - lo;
if !range.is_finite() || range <= 0.0 {
return Err(ScErrors::PalantirDegeneratePseudotime {
reason: "pseudotime collapsed to a constant",
});
}
Ok(pseudotime
.par_iter()
.map(|&t| ((t - lo) / range) as f32)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn chain_knn(n: usize) -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let mut indices = Vec::with_capacity(n);
let mut distances = Vec::with_capacity(n);
for i in 0..n {
let mut idx = Vec::new();
let mut dst = Vec::new();
if i > 0 {
idx.push(i - 1);
dst.push(1.0);
}
if i + 1 < n {
idx.push(i + 1);
dst.push(1.0);
}
indices.push(idx);
distances.push(dst);
}
(indices, distances)
}
#[test]
fn test_symmetric_graph_does_not_double_weight_mutual_edges() {
let indices = vec![vec![1], vec![0]];
let distances = vec![vec![3.0], vec![3.0]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
assert_eq!(graph.get_nnz(), 2);
for &w in &graph.data {
assert_relative_eq!(w, 3.0, epsilon = 1e-6);
}
}
#[test]
fn test_symmetric_graph_is_symmetric_for_one_sided_edges() {
let indices = vec![vec![1], vec![]];
let distances = vec![vec![2.0], vec![]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
assert_eq!(graph.indptr[1] - graph.indptr[0], 1);
assert_eq!(graph.indptr[2] - graph.indptr[1], 1);
}
#[test]
fn test_connect_graph_bridges_two_islands() {
let indices = vec![vec![1], vec![0], vec![3], vec![2]];
let distances = vec![vec![1.0], vec![1.0], vec![1.0], vec![1.0]];
let data = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let (repaired, added) = connect_graph(&graph, &data, 0).unwrap();
assert_eq!(added, 1);
let (n_comps, _) = connected_components(&repaired).unwrap();
assert_eq!(n_comps, 1);
let mut dist = vec![0.0; 4];
dijkstra_from_source(&repaired, 0, &mut dist).unwrap();
assert!(dist.iter().all(|d| d.is_finite()));
}
#[test]
fn test_connect_graph_leaves_connected_input_alone() {
let (indices, distances) = chain_knn(6);
let data: Vec<Vec<f32>> = (0..6).map(|i| vec![i as f32]).collect();
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let (repaired, added) = connect_graph(&graph, &data, 0).unwrap();
assert_eq!(added, 0);
assert_eq!(repaired.get_nnz(), graph.get_nnz());
}
#[test]
fn test_weight_columns_sum_to_one() {
let (indices, distances) = chain_knn(40);
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let waypoints: Vec<usize> = (0..40).step_by(5).collect();
let res = compute_pseudotime(&graph, &waypoints, 0, 25, Verbosity::Quiet).unwrap();
for c in 0..res.n_cells {
let sum: f64 = (0..res.n_waypoints)
.map(|w| res.weights[w * res.n_cells + c] as f64)
.sum();
assert_relative_eq!(sum, 1.0, epsilon = 1e-5);
}
}
#[test]
fn test_pseudotime_monotone_on_a_chain() {
let (indices, distances) = chain_knn(40);
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let waypoints: Vec<usize> = std::iter::once(0).chain((5..40).step_by(5)).collect();
let res = compute_pseudotime(&graph, &waypoints, 0, 25, Verbosity::Quiet).unwrap();
assert_relative_eq!(res.pseudotime[0], 0.0, epsilon = 1e-6);
assert_relative_eq!(res.pseudotime[39], 1.0, epsilon = 1e-6);
for i in 0..39 {
assert!(
res.pseudotime[i] < res.pseudotime[i + 1],
"pseudotime not increasing at {i}: {} then {}",
res.pseudotime[i],
res.pseudotime[i + 1]
);
}
}
#[test]
fn test_fused_kernel_matches_naive() {
let (n_wp, n_cells) = (7usize, 23usize);
let geodesic: Vec<f64> = (0..n_wp * n_cells)
.map(|i| 1.0 + ((i * 37) % 19) as f64 * 0.5)
.collect();
let weights: Vec<f32> = (0..n_wp * n_cells)
.map(|i| 0.05 + ((i * 11) % 7) as f32 * 0.1)
.collect();
let pseudotime: Vec<f64> = (0..n_cells).map(|i| ((i * 5) % 13) as f64 * 0.25).collect();
let waypoints: Vec<usize> = (0..n_wp).map(|w| (w * 3) % n_cells).collect();
let mut fused = vec![0.0f64; n_cells];
refine_once(
&geodesic,
&weights,
&pseudotime,
&waypoints,
n_cells,
&mut fused,
);
let mut t_wp: Vec<f64> = waypoints.iter().map(|&w| pseudotime[w]).collect();
let mut signs = vec![1.0f64; n_wp * n_cells];
for w in 0..n_wp {
for c in 0..n_cells {
if pseudotime[c] < t_wp[w] {
signs[w * n_cells + c] = -1.0;
}
}
}
t_wp[0] = 0.0;
for c in 0..n_cells {
signs[c] = 1.0;
}
let mut naive = vec![0.0f64; n_cells];
for w in 0..n_wp {
for c in 0..n_cells {
let p = geodesic[w * n_cells + c] * signs[w * n_cells + c] + t_wp[w];
naive[c] += p * weights[w * n_cells + c] as f64;
}
}
for c in 0..n_cells {
assert_relative_eq!(fused[c], naive[c], epsilon = 1e-12);
}
}
#[test]
fn test_weights_survive_an_underflowing_geodesic() {
let (n_wp, n_cells) = (4usize, 200usize);
let far = n_cells - 1;
let mut geodesic = vec![0.0f64; n_wp * n_cells];
for wp in 0..n_wp {
for c in 0..n_cells {
geodesic[wp * n_cells + c] = if c == far {
600.0 + wp as f64
} else {
1.0 + ((wp + c) % 3) as f64 * 0.5
};
}
}
let sdv = population_standard_deviation(&geodesic)
* SILVERMAN_FACTOR
* (n_wp as f64 * n_cells as f64).powf(SILVERMAN_EXPONENT);
let naive_sum: f64 = (0..n_wp)
.map(|wp| {
let d = geodesic[wp * n_cells + far];
((-0.5 * (d / sdv) * (d / sdv)).exp() as f32) as f64
})
.sum();
assert_eq!(
naive_sum, 0.0,
"the fixture does not underflow, so it cannot cover the shift"
);
let weights = gaussian_waypoint_weights(&geodesic, n_wp, n_cells).unwrap();
assert!(weights.iter().all(|w| w.is_finite()));
for c in 0..n_cells {
let sum: f64 = (0..n_wp).map(|w| weights[w * n_cells + c] as f64).sum();
assert_relative_eq!(sum, 1.0, epsilon = 1e-5);
}
assert!(weights[far] > 0.9, "nearest weight is {}", weights[far]);
}
#[test]
fn test_symmetric_graph_keeps_the_minimum_weight_on_a_disagreeing_pair() {
let indices = vec![vec![1], vec![0]];
let forward = vec![vec![2.0], vec![5.0]];
let backward = vec![vec![5.0], vec![2.0]];
for distances in [forward, backward] {
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
assert_eq!(graph.get_nnz(), 2);
for &w in &graph.data {
assert_relative_eq!(w, 2.0, epsilon = 1e-6);
}
}
}
#[test]
fn test_symmetric_graph_accepts_empty_input() {
let graph = build_symmetric_knn_graph(&[], &[]).unwrap();
assert_eq!(graph.shape, (0, 0));
assert_eq!(graph.get_nnz(), 0);
}
#[test]
fn test_csr_rows_come_out_sorted() {
let (indices, distances) = chain_knn(12);
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
for i in 0..12 {
let (lo, hi) = (graph.indptr[i] as usize, graph.indptr[i + 1] as usize);
assert!(
graph.indices[lo..hi].windows(2).all(|w| w[0] < w[1]),
"row {i} is not ascending: {:?}",
&graph.indices[lo..hi]
);
}
}
#[test]
fn test_connect_graph_gives_up_instead_of_spinning() {
let indices = vec![vec![1], vec![0], vec![3], vec![2]];
let distances = vec![vec![1.0], vec![1.0], vec![1.0], vec![1.0]];
let data = vec![vec![0.0f32], vec![1.0], vec![f32::NAN], vec![f32::NAN]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
assert!(matches!(
connect_graph(&graph, &data, 0),
Err(ScErrors::PalantirDisconnectedGraph {
n_unreachable: 2,
..
})
));
}
#[test]
fn test_connect_graph_rejects_mismatched_data_rows() {
let indices = vec![vec![1], vec![0], vec![3], vec![2]];
let distances = vec![vec![1.0], vec![1.0], vec![1.0], vec![1.0]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let data = vec![vec![0.0], vec![1.0]];
assert!(matches!(
connect_graph(&graph, &data, 0),
Err(ScErrors::Core(CoreErrors::DimensionMisMatchSparse { .. }))
));
}
#[test]
fn test_pseudotime_rejects_a_useless_iteration_cap() {
let (indices, distances) = chain_knn(20);
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let waypoints: Vec<usize> = (0..20).step_by(4).collect();
for cap in [0usize, 1usize] {
assert!(
matches!(
compute_pseudotime(&graph, &waypoints, 0, cap, Verbosity::Quiet),
Err(ScErrors::PalantirMaxIterationsTooSmall { .. })
),
"a cap of {cap} was silently accepted"
);
}
let res = compute_pseudotime(&graph, &waypoints, 0, 2, Verbosity::Quiet).unwrap();
assert_eq!(res.iterations, 1);
}
#[test]
fn test_pseudotime_rejects_a_start_cell_that_is_not_waypoint_zero() {
let (indices, distances) = chain_knn(20);
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let waypoints: Vec<usize> = (0..20).step_by(4).collect();
assert!(matches!(
compute_pseudotime(&graph, &waypoints, 8, 25, Verbosity::Quiet),
Err(ScErrors::PalantirStartCellNotFirstWaypoint {
start_cell: 8,
first_waypoint: 0
})
));
}
#[test]
fn test_unreachable_count_is_in_cells_not_matrix_entries() {
let n_cells = 4usize;
let mut geodesic = vec![0.0f64; 2 * n_cells];
for wp in 0..2 {
geodesic[wp * n_cells + 2] = f64::INFINITY;
geodesic[wp * n_cells + 3] = f64::INFINITY;
}
assert_eq!(count_unreachable_cells(&geodesic, 2, n_cells), 2);
}
#[test]
fn test_pseudotime_rejects_a_disconnected_graph() {
let indices = vec![vec![1], vec![0], vec![3], vec![2]];
let distances = vec![vec![1.0], vec![1.0], vec![1.0], vec![1.0]];
let graph = build_symmetric_knn_graph(&indices, &distances).unwrap();
let err = compute_pseudotime(&graph, &[0, 1], 0, 25, Verbosity::Quiet);
assert!(matches!(
err,
Err(ScErrors::PalantirUnreachableFromWaypoints { n_unreachable: 2 })
));
}
}