use faer::Mat;
use rustc_hash::FxHashMap;
use crate::prelude::*;
use crate::single_cell::sc_trajectory::diffusion::{
assemble_waypoints, boundary_cells, minmax_scale_columns, multiscale_components,
nearest_candidate,
};
use crate::single_cell::sc_trajectory::markov::{
absorption_probabilities, branch_entropy, build_waypoint_transitions, detect_terminal_states,
project_to_cells,
};
use crate::single_cell::sc_trajectory::pseudotime::{
build_symmetric_knn_graph, compute_pseudotime, connect_graph,
};
#[derive(Clone, Debug)]
pub struct PalantirParams {
pub n_dcs: usize,
pub n_eigs: Option<usize>,
pub knn: usize,
pub num_waypoints: usize,
pub scale_components: bool,
pub use_early_cell_as_start: bool,
pub max_iterations: usize,
pub branch_prob_threshold: f32,
pub knn_params: KnnParams,
pub lanczos_params: LanczosParams,
}
impl PalantirParams {
pub fn new() -> Self {
Self {
n_dcs: 10,
n_eigs: None,
knn: 30,
num_waypoints: 1200,
scale_components: true,
use_early_cell_as_start: true,
max_iterations: 25,
branch_prob_threshold: 0.01,
knn_params: KnnParams::new(),
lanczos_params: LanczosParams::new(),
}
}
}
impl Default for PalantirParams {
fn default() -> Self {
Self::new()
}
}
pub struct PalantirResult {
pub pseudotime: Vec<f32>,
pub entropy: Vec<f32>,
pub branch_probs: Mat<f32>,
pub terminal_states: Vec<usize>,
pub waypoints: Vec<usize>,
pub start_cell: usize,
pub multiscale: Mat<f32>,
pub iterations: usize,
pub converged: bool,
pub eigen_converged: bool,
pub eigen_residual: f64,
pub repair_edges: usize,
pub stranded_waypoints: usize,
}
fn geodesic_graph(
multiscale: faer::MatRef<f32>,
params: &PalantirParams,
seed: u64,
verbose: bool,
) -> Result<CompressedSparseData2<f32>, BixverseErrors> {
let n_cells = multiscale.nrows();
let mut knn_params = params.knn_params.clone();
knn_params.k = params
.knn
.saturating_sub(1)
.clamp(1, n_cells.saturating_sub(1).max(1));
knn_params.ann_dist = "euclidean".to_string();
let (indices, distances) =
generate_knn_with_dist(multiscale, &knn_params, true, false, seed as usize, verbose)?;
let distances = distances.ok_or(BixverseErrors::InvalidArgument(
"Palantir: the multiscale kNN search returned no distances".to_string(),
))?;
build_symmetric_knn_graph(&indices, &distances, true)
}
fn waypoint_positions(waypoints: &[usize], states: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
let lookup: FxHashMap<usize, usize> = waypoints
.iter()
.enumerate()
.map(|(pos, &cell)| (cell, pos))
.collect();
let mut out: Vec<usize> = states
.iter()
.map(|&s| {
lookup
.get(&s)
.copied()
.ok_or(BixverseErrors::PalantirTerminalStateNotAWaypoint {
state: s,
n_waypoints: waypoints.len(),
})
})
.collect::<Result<Vec<usize>, BixverseErrors>>()?;
out.sort_unstable();
out.dedup();
Ok(out)
}
fn apply_probability_threshold(branch_probs: &mut Mat<f32>, threshold: f32) {
if threshold <= 0.0 {
return;
}
for i in 0..branch_probs.nrows() {
for j in 0..branch_probs.ncols() {
if branch_probs[(i, j)] < threshold {
branch_probs[(i, j)] = 0.0;
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_palantir(
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
squared_dist: bool,
early_cell: usize,
terminal_states: Option<&[usize]>,
params: PalantirParams,
seed: u64,
verbose: usize,
) -> Result<PalantirResult, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let n_cells = knn_indices.len();
if n_cells == 0 {
return Err(BixverseErrors::InvalidArgument(
"Palantir: the kNN graph is empty".to_string(),
));
}
if early_cell >= n_cells {
return Err(BixverseErrors::PalantirEarlyCellOutOfRange {
early_cell,
n_cells,
});
}
if let Some(states) = terminal_states
&& let Some(&bad) = states.iter().find(|&&s| s >= n_cells)
{
return Err(BixverseErrors::PalantirTerminalStateOutOfRange {
state: bad,
n_cells,
});
}
if verbosity.normal_verbosity() {
println!("Building the multiscale diffusion space...");
}
let space = multiscale_components(
knn_indices,
knn_distances,
squared_dist,
params.n_dcs,
params.n_eigs,
seed,
Some(params.lanczos_params),
)?;
let (eigen_converged, eigen_residual) = (space.converged, space.residual);
if !eigen_converged && verbosity.normal_verbosity() {
println!(
"[WARNING] The diffusion eigensolve did not converge (residual {eigen_residual:e}); the embedding is under-resolved. Raise lanczos_max_restarts."
);
}
let mut multiscale = space.components;
if params.scale_components {
minmax_scale_columns(&mut multiscale);
}
let n_dims = multiscale[0].len();
let boundaries = boundary_cells(&multiscale);
let start_cell = if params.use_early_cell_as_start {
early_cell
} else {
nearest_candidate(&multiscale, &boundaries, early_cell)?
};
let waypoints = assemble_waypoints(
&multiscale,
params.num_waypoints,
&boundaries,
terminal_states,
start_cell,
seed,
);
if verbosity.normal_verbosity() {
println!(
"Sampled {} waypoints; start cell is {start_cell}.",
waypoints.len()
);
}
let multiscale_mat = Mat::<f32>::from_fn(n_cells, n_dims, |i, j| multiscale[i][j]);
let graph = geodesic_graph(
multiscale_mat.as_ref(),
¶ms,
seed,
verbosity.detailed_verbosity(),
)?;
let (graph, repair_edges) = connect_graph(&graph, &multiscale, start_cell)?;
if repair_edges > 0 && verbosity.normal_verbosity() {
println!(
"[WARNING] The kNN graph was disconnected; added {repair_edges} bridging edges. Consider a larger knn."
);
}
let pseudotime = compute_pseudotime(
&graph,
&waypoints,
start_cell,
params.max_iterations,
verbosity,
)?;
let wp_data: Vec<Vec<f32>> = waypoints.iter().map(|&w| multiscale[w].clone()).collect();
let wp_pseudotime: Vec<f32> = waypoints
.iter()
.map(|&w| pseudotime.pseudotime[w])
.collect();
if verbosity.normal_verbosity() {
println!("Constructing the waypoint Markov chain...");
}
let transitions = build_waypoint_transitions(
&wp_data,
&wp_pseudotime,
params.knn,
verbosity.detailed_verbosity(),
)?;
let mut absorbing = match terminal_states {
Some(states) => waypoint_positions(&waypoints, states)?,
None => detect_terminal_states(&transitions, &wp_data, &wp_pseudotime)?,
};
absorbing.sort_unstable_by_key(|&p| waypoints[p]);
if verbosity.normal_verbosity() {
println!("Solving for {} terminal states...", absorbing.len());
}
let (branch_probs_wp, stranded_waypoints) = absorption_probabilities(&transitions, &absorbing)?;
if stranded_waypoints > 0 && verbosity.normal_verbosity() {
println!(
"[WARNING] {stranded_waypoints} waypoints cannot reach any terminal state and were left without fate probabilities."
);
}
let mut branch_probs = project_to_cells(
&pseudotime.weights,
pseudotime.n_waypoints,
pseudotime.n_cells,
branch_probs_wp.as_ref(),
)?;
let entropy = branch_entropy(branch_probs.as_ref());
apply_probability_threshold(&mut branch_probs, params.branch_prob_threshold);
let terminal_states: Vec<usize> = absorbing.iter().map(|&p| waypoints[p]).collect();
Ok(PalantirResult {
pseudotime: pseudotime.pseudotime,
entropy,
branch_probs,
terminal_states,
waypoints,
start_cell,
multiscale: multiscale_mat,
iterations: pseudotime.iterations,
converged: pseudotime.converged,
eigen_converged,
eigen_residual,
repair_edges,
stranded_waypoints,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn jitter(i: usize, salt: usize) -> f32 {
let h = (i.wrapping_mul(2_654_435_761) ^ salt.wrapping_mul(40_503)) % 10_007;
h as f32 / 10_007.0 - 0.5
}
fn knn_of(coords: &[Vec<f32>], k: usize) -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let n = coords.len();
let d = coords[0].len();
let mat = Mat::<f32>::from_fn(n, d, |i, j| coords[i][j]);
let mut params = KnnParams::new();
params.knn_method = "exhaustive".to_string();
params.ann_dist = "euclidean".to_string();
params.k = k;
let (indices, distances) =
generate_knn_with_dist(mat.as_ref(), ¶ms, true, false, 42, false).unwrap();
(indices, distances.unwrap())
}
fn linear_manifold(n: usize) -> Vec<Vec<f32>> {
(0..n)
.map(|i| {
let t = i as f32 / (n - 1) as f32;
vec![t * 10.0, jitter(i, 1) * 0.6, jitter(i, 2) * 0.6]
})
.collect()
}
const ARM_DIVERGENCE: f32 = 1.4;
fn y_manifold(trunk: usize, arm: usize) -> Vec<Vec<f32>> {
let mut coords = Vec::with_capacity(trunk + 2 * arm);
for i in 0..trunk {
let t = i as f32 / trunk as f32 * 5.0;
coords.push(vec![t, jitter(i, 3) * 0.3]);
}
for i in 0..arm {
let t = (i + 1) as f32 / arm as f32 * 5.0;
coords.push(vec![5.0 + t, t * ARM_DIVERGENCE + jitter(i, 4) * 0.3]);
}
for i in 0..arm {
let t = (i + 1) as f32 / arm as f32 * 5.0;
coords.push(vec![5.0 + t, -(t * ARM_DIVERGENCE + jitter(i, 4) * 0.3)]);
}
coords
}
const Y_TRUNK: usize = 100;
const Y_ARM: usize = 100;
#[cfg(feature = "large_scale_diagnostics")]
const Y_TRUNK_LARGE: usize = 200;
#[cfg(feature = "large_scale_diagnostics")]
const Y_ARM_LARGE: usize = 200;
fn test_params(num_waypoints: usize) -> PalantirParams {
let mut params = PalantirParams::new();
params.knn = 24;
params.num_waypoints = num_waypoints;
params.knn_params.knn_method = "exhaustive".to_string();
params.n_eigs = Some(3);
params.lanczos_params.max_restarts = 256;
params
}
#[test]
fn test_linear_trajectory_has_monotone_pseudotime() {
let coords = linear_manifold(120);
let (indices, distances) = knn_of(&coords, 15);
let res =
run_palantir(&indices, &distances, true, 0, None, test_params(250), 42, 0).unwrap();
let truth: Vec<f32> = (0..120).map(|i| i as f32).collect();
let corr = crate::core::math::vector_helpers::pearson_correlation(&res.pseudotime, &truth)
.unwrap();
assert!(
corr > 0.95,
"pseudotime does not track the manifold: r = {corr}"
);
assert!(
res.pseudotime[0] < 0.05,
"the start cell sits at pseudotime {}, so the trajectory runs backwards",
res.pseudotime[0]
);
assert_eq!(res.terminal_states.len(), 1);
}
#[test]
fn test_y_branch_terminal_states_lie_in_the_arms() {
let (trunk, arm) = (Y_TRUNK, Y_ARM);
let coords = y_manifold(trunk, arm);
let (indices, distances) = knn_of(&coords, 15);
let res =
run_palantir(&indices, &distances, true, 0, None, test_params(120), 42, 0).unwrap();
assert_eq!(
res.repair_edges, 0,
"the Y fixture is disconnected; the arms have detached from the trunk"
);
assert!(
res.eigen_converged,
"the eigensolve stopped at residual {:e}; the embedding is not determined",
res.eigen_residual
);
assert!(
!res.terminal_states.is_empty(),
"no terminal states detected"
);
assert!(
res.terminal_states.iter().all(|&c| c >= trunk),
"a trunk cell was called terminal: {:?}",
res.terminal_states
);
assert!(
res.terminal_states.windows(2).all(|w| w[0] < w[1]),
"terminal states are not ascending: {:?}",
res.terminal_states
);
}
#[test]
#[cfg(feature = "large_scale_diagnostics")]
fn test_y_branch_detection_finds_one_state_per_arm() {
let (trunk, arm) = (Y_TRUNK_LARGE, Y_ARM_LARGE);
let coords = y_manifold(trunk, arm);
let (indices, distances) = knn_of(&coords, 15);
let res =
run_palantir(&indices, &distances, true, 0, None, test_params(250), 42, 0).unwrap();
assert_eq!(res.repair_edges, 0);
assert!(
res.eigen_converged,
"the eigensolve stopped at residual {:e}",
res.eigen_residual
);
assert_eq!(
res.terminal_states.len(),
2,
"expected one terminal state per arm, got {:?}",
res.terminal_states
);
assert!(
(trunk..trunk + arm).contains(&res.terminal_states[0])
&& (trunk + arm..trunk + 2 * arm).contains(&res.terminal_states[1]),
"terminal states do not sit one per arm: {:?}",
res.terminal_states
);
}
#[test]
fn test_y_branch_fate_probabilities_split_at_the_root() {
let (trunk, arm) = (Y_TRUNK, Y_ARM);
let coords = y_manifold(trunk, arm);
let (indices, distances) = knn_of(&coords, 15);
let tips = [trunk + arm - 1, trunk + 2 * arm - 1];
let res = run_palantir(
&indices,
&distances,
true,
0,
Some(&tips),
test_params(120),
42,
0,
)
.unwrap();
assert_eq!(res.terminal_states, tips.to_vec());
for (col, &tip) in res.terminal_states.iter().enumerate() {
assert!(
res.branch_probs[(tip, col)] > 0.95,
"tip {tip} gives only {} to its own fate",
res.branch_probs[(tip, col)]
);
assert!(
res.entropy[tip] < 0.2,
"terminal state {tip} has entropy {}",
res.entropy[tip]
);
}
let mid_trunk = trunk / 2;
let split = (res.branch_probs[(mid_trunk, 0)] - res.branch_probs[(mid_trunk, 1)]).abs();
assert!(
split < 0.2,
"mid-trunk fates are not close to even, difference {split}"
);
assert!(
res.entropy[mid_trunk] > 0.65,
"mid-trunk entropy is only {}, expected near ln 2",
res.entropy[mid_trunk]
);
assert!(
res.entropy[0] > 0.65,
"root entropy is only {}, expected near ln 2",
res.entropy[0]
);
assert_eq!(res.stranded_waypoints, 0);
assert_eq!(res.repair_edges, 0);
}
#[test]
fn test_branch_probs_are_bounded_and_split_across_two_absorbers() {
let (trunk, arm) = (Y_TRUNK, Y_ARM);
let coords = y_manifold(trunk, arm);
let (indices, distances) = knn_of(&coords, 15);
let supplied = [trunk + arm - 1, trunk + 2 * arm - 1];
let mut params = test_params(120);
params.branch_prob_threshold = 0.0;
let res = run_palantir(
&indices,
&distances,
true,
0,
Some(&supplied),
params,
42,
0,
)
.unwrap();
assert_eq!(res.branch_probs.ncols(), 2);
for i in 0..res.branch_probs.nrows() {
let sum: f32 = (0..2).map(|j| res.branch_probs[(i, j)]).sum();
assert!(
(0.0..=1.0 + 1e-4).contains(&sum),
"cell {i} has fate probabilities summing to {sum}"
);
}
let n_cells = res.branch_probs.nrows();
for j in 0..2 {
let column_mass: f32 = (0..n_cells).map(|i| res.branch_probs[(i, j)]).sum();
assert!(
column_mass > 0.1 * n_cells as f32,
"fate {j} carries only {column_mass} across {n_cells} cells"
);
}
}
#[test]
fn test_user_supplied_terminal_states_are_honoured() {
let coords = y_manifold(Y_TRUNK, Y_ARM);
let (indices, distances) = knn_of(&coords, 15);
let supplied = [Y_TRUNK + Y_ARM - 40, Y_TRUNK + 2 * Y_ARM - 40];
let res = run_palantir(
&indices,
&distances,
true,
0,
Some(&supplied),
test_params(120),
42,
0,
)
.unwrap();
assert_eq!(res.terminal_states, supplied.to_vec());
}
#[test]
fn test_rejects_out_of_range_early_cell() {
let coords = linear_manifold(60);
let (indices, distances) = knn_of(&coords, 10);
assert!(matches!(
run_palantir(
&indices,
&distances,
true,
999,
None,
test_params(250),
42,
0
),
Err(BixverseErrors::PalantirEarlyCellOutOfRange { .. })
));
}
#[test]
fn test_rejects_out_of_range_terminal_state() {
let coords = linear_manifold(60);
let (indices, distances) = knn_of(&coords, 10);
assert!(matches!(
run_palantir(
&indices,
&distances,
true,
0,
Some(&[999]),
test_params(250),
42,
0
),
Err(BixverseErrors::PalantirTerminalStateOutOfRange { .. })
));
}
#[test]
fn test_rejects_knn_below_the_bandwidth_floor() {
let coords = linear_manifold(60);
let (indices, distances) = knn_of(&coords, 10);
let mut params = test_params(250);
params.knn = 4;
assert!(matches!(
run_palantir(&indices, &distances, true, 0, None, params, 42, 0),
Err(BixverseErrors::PalantirKnnTooSmall { .. })
));
}
}