use faer::{Mat, MatRef, concat};
use half::f16;
use indexmap::IndexSet;
use rand::{Rng, RngCore, SeedableRng, rngs::StdRng};
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use std::time::Instant;
use crate::core::math::pca_svd::*;
use crate::core::math::sparse::sparse_svd_lanczos;
use crate::prelude::*;
const GENE_BATCH_SIZE: usize = 1000;
use crate::single_cell::sc_processing::hvg::*;
use crate::single_cell::sc_processing::pca::*;
pub const MAX_DOUBLET_K_NEIGHBOURS: usize = 250;
pub const FAST_CLUSTER_MIN_CELLS: usize = 10_000;
pub struct HvgOpts {
pub min_gene_var_pctl: f32,
pub method: String,
pub loess_span: f32,
pub clip_max: Option<f32>,
pub n_bins: usize,
pub binning_strategy: String,
}
pub struct PcaOpts {
pub log_transform: bool,
pub mean_center: bool,
pub normalise_variance: bool,
pub no_pcs: usize,
pub random_svd: bool,
}
pub fn select_hvg<S: SingleCellReading>(
reader: &S,
cells_to_keep: &[usize],
opts: &HvgOpts,
streaming: bool,
verbose: usize,
) -> Result<Vec<usize>, BixverseErrors> {
let hvg_type = parse_hvg_method(&opts.method).unwrap_or_default();
let sort_key: Vec<f64> = match hvg_type {
HvgMethod::Vst => {
let res = if streaming {
get_hvg_vst_streaming(
reader,
cells_to_keep,
opts.loess_span,
opts.clip_max,
verbose,
)
} else {
get_hvg_vst(
reader,
cells_to_keep,
opts.loess_span,
opts.clip_max,
verbose,
)
}?;
res.var_std
}
HvgMethod::Dispersion => {
let res = if streaming {
get_hvg_dispersion_streaming(
reader,
cells_to_keep,
&opts.binning_strategy,
opts.n_bins,
verbose,
)
} else {
get_hvg_dispersion(
reader,
cells_to_keep,
&opts.binning_strategy,
opts.n_bins,
verbose,
)
}?;
res.dispersion
}
HvgMethod::MeanVarBin => {
let res = if streaming {
get_hvg_mvb_streaming(
reader,
cells_to_keep,
&opts.binning_strategy,
opts.n_bins,
verbose,
)
} else {
get_hvg_mvb(
reader,
cells_to_keep,
&opts.binning_strategy,
opts.n_bins,
verbose,
)
}?;
res.dispersion_scaled
}
};
let n_genes = sort_key.len() as f32;
let n_to_take = (n_genes * (1.0 - opts.min_gene_var_pctl)).ceil() as usize;
let mut indices: Vec<(usize, f64)> = sort_key.into_iter().enumerate().collect();
indices.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
indices.truncate(n_to_take);
let mut result: Vec<usize> = indices.into_iter().map(|(i, _)| i).collect();
result.sort_unstable();
Ok(result)
}
pub fn compute_hvg_library_sizes<S: SingleCellReading>(
reader: &S,
cells_to_keep: &[usize],
hvg_genes: &[usize],
) -> Result<Vec<usize>, BixverseErrors> {
let hvg_set: FxHashSet<usize> = hvg_genes.iter().copied().collect();
cells_to_keep
.par_iter()
.map(|&cell_idx| -> Result<usize, BixverseErrors> {
let chunk = reader.read_cell(cell_idx)?;
Ok(chunk
.indices
.iter()
.enumerate()
.filter(|&(_, &gene_idx)| hvg_set.contains(&(gene_idx as usize)))
.map(|(i, _)| chunk.data_raw.get(i) as usize)
.sum())
})
.collect()
}
pub fn resolve_target_size(explicit: Option<f32>, hvg_library_sizes: &[usize]) -> f32 {
explicit.unwrap_or_else(|| {
let sum = hvg_library_sizes.iter().sum::<usize>() as f32;
sum / hvg_library_sizes.len() as f32
})
}
pub type DoubletPcaRes = (Mat<f32>, Mat<f32>, Vec<f32>, Vec<f32>);
pub fn scale_cell_chunks_with_stats(
chunks: &[CsrCellChunk],
gene_means: &[f32],
gene_stds: &[f32],
mean_center: bool,
normalise_variance: bool,
n_genes: usize,
) -> Mat<f32> {
let n_cells = chunks.len();
let mut result = Mat::<f32>::zeros(n_cells, n_genes);
for (cell_idx, chunk) in chunks.iter().enumerate() {
match (mean_center, normalise_variance) {
(false, false) => {
for i in 0..chunk.indices.len() {
let gene = chunk.indices[i] as usize;
let val = chunk.data_norm[i].to_f32();
*result.get_mut(cell_idx, gene) = val;
}
}
(true, false) => {
for gene in 0..n_genes {
*result.get_mut(cell_idx, gene) = -gene_means[gene];
}
for i in 0..chunk.indices.len() {
let gene = chunk.indices[i] as usize;
let val = chunk.data_norm[i].to_f32();
*result.get_mut(cell_idx, gene) = val - gene_means[gene];
}
}
(false, true) => {
for i in 0..chunk.indices.len() {
let gene = chunk.indices[i] as usize;
let val = chunk.data_norm[i].to_f32();
*result.get_mut(cell_idx, gene) = val / gene_stds[gene];
}
}
(true, true) => {
for gene in 0..n_genes {
*result.get_mut(cell_idx, gene) = -gene_means[gene] / gene_stds[gene];
}
for i in 0..chunk.indices.len() {
let gene = chunk.indices[i] as usize;
let val = chunk.data_norm[i].to_f32();
*result.get_mut(cell_idx, gene) = (val - gene_means[gene]) / gene_stds[gene];
}
}
}
}
result
}
#[allow(clippy::too_many_arguments)]
pub fn pca_observed<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
gene_indices: &[usize],
hvg_library_sizes: &[usize],
target_size: f32,
log_transform: bool,
mean_center: bool,
normalise_variance: bool,
no_pcs: usize,
random_svd: bool,
seed: usize,
verbose: usize,
) -> Result<DoubletPcaRes, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_total = Instant::now();
let cell_set: IndexSet<u32> = cell_indices.iter().map(|&x| x as u32).collect();
let n_cells = cell_indices.len();
let start_reading = Instant::now();
let mut gene_chunks: Vec<CscGeneChunk> =
reader.read_gene_parallel_filtered(gene_indices, &cell_set)?;
if verbosity.normal_verbosity() {
println!("Loaded in data: {:.2?}", start_reading.elapsed());
}
let start_prep = Instant::now();
gene_chunks.par_iter_mut().for_each(|chunk| {
for (i, &pos) in chunk.indices.iter().enumerate() {
let raw_count = chunk.data_raw.get(i) as f32;
let lib_size = hvg_library_sizes[pos as usize] as f32;
let val = if log_transform {
((raw_count / lib_size) * target_size).ln_1p()
} else {
(raw_count / lib_size) * target_size
};
chunk.data_norm[i] = F16::from(half::f16::from_f32(val.clamp(-65504.0, 65504.0)));
}
});
let csc = from_gene_chunks::<f32>(gene_chunks, &DataLayerReturn::Norm, n_cells)?;
let col_means: Vec<f64> = sparse_csc_column_means(&csc, true, None)?;
let col_stds: Vec<f64> = sparse_csc_column_stds(&csc, &col_means, true, None)?;
let means_for_svd = if mean_center {
Some(&col_means[..])
} else {
None
};
let stds_for_svd = if normalise_variance {
Some(&col_stds[..])
} else {
None
};
if verbosity.normal_verbosity() {
println!("Finished data preparation: {:.2?}", start_prep.elapsed());
}
let start_svd = Instant::now();
let (scores, loadings) = if random_svd {
let svd_res = randomised_sparse_svd::<f32, f64>(
csc,
no_pcs,
seed as u64,
true,
Some(100_usize),
None,
means_for_svd,
stds_for_svd,
None,
)?;
let scores_f64 = compute_pc_scores(&svd_res);
let scores = Mat::<f32>::from_fn(n_cells, no_pcs, |i, j| scores_f64[(i, j)] as f32);
let loadings = Mat::<f32>::from_fn(gene_indices.len(), no_pcs, |i, j| {
svd_res.v()[(i, j)] as f32
});
(scores, loadings)
} else {
let svd_res = sparse_svd_lanczos::<f32, f32, f64>(
&csc,
no_pcs,
seed as u64,
true,
means_for_svd,
stds_for_svd,
None,
)?;
let scores_f64 = compute_pc_scores(&svd_res);
let scores = Mat::<f32>::from_fn(scores_f64.nrows(), scores_f64.ncols(), |i, j| {
scores_f64[(i, j)] as f32
});
let loadings = Mat::<f32>::from_fn(svd_res.v().nrows(), svd_res.v().ncols(), |i, j| {
svd_res.v()[(i, j)] as f32
});
(scores, loadings)
};
if verbosity.normal_verbosity() {
println!("Finished PCA calculations: {:.2?}", start_svd.elapsed());
println!(
"Total run time PCA detection: {:.2?}",
start_total.elapsed()
);
}
let col_means_f32: Vec<f32> = col_means.iter().map(|&x| x as f32).collect();
let col_stds_f32: Vec<f32> = col_stds.iter().map(|&x| x as f32).collect();
Ok((scores, loadings, col_means_f32, col_stds_f32))
}
#[allow(clippy::too_many_arguments)]
pub fn pca_and_project<S: SingleCellReading>(
reader: &S,
cells_to_keep: &[usize],
hvg_genes: &[usize],
hvg_library_sizes: &[usize],
target_size: f32,
sim_chunks: &[CsrCellChunk],
opts: &PcaOpts,
seed: usize,
verbose: usize,
) -> Result<(Mat<f32>, DoubletPcaRes), BixverseErrors> {
let pca_res = pca_observed(
reader,
cells_to_keep,
hvg_genes,
hvg_library_sizes,
target_size,
opts.log_transform,
opts.mean_center,
opts.normalise_variance,
opts.no_pcs,
opts.random_svd,
seed,
verbose,
)?;
let scaled_sim = scale_cell_chunks_with_stats(
sim_chunks,
&pca_res.2,
&pca_res.3,
opts.mean_center,
opts.normalise_variance,
hvg_genes.len(),
);
let sim_pca = &scaled_sim * &pca_res.1;
let combined = concat![[&pca_res.0], [sim_pca]];
Ok((combined, pca_res))
}
pub fn reproject_doublets(
sim_chunks: &[CsrCellChunk],
pca_res: &DoubletPcaRes,
opts: &PcaOpts,
) -> Mat<f32> {
let scaled_sim = scale_cell_chunks_with_stats(
sim_chunks,
&pca_res.2,
&pca_res.3,
opts.mean_center,
opts.normalise_variance,
pca_res.2.len(),
);
let sim_pca = &scaled_sim * &pca_res.1;
concat![[&pca_res.0], [sim_pca]]
}
pub fn dispatch_knn(
embd: MatRef<f32>,
k: usize,
knn_params: &KnnParams,
seed: usize,
verbose: bool,
) -> Result<Vec<Vec<usize>>, BixverseErrors> {
let method = parse_knn_method(&knn_params.knn_method).unwrap_or_default();
match method {
KnnSearch::Hnsw => generate_knn_hnsw(
embd,
&knn_params.ann_dist,
k,
knn_params.m,
knn_params.ef_construction,
knn_params.ef_search,
seed,
false,
verbose,
),
KnnSearch::Annoy => generate_knn_annoy(
embd,
&knn_params.ann_dist,
k,
knn_params.n_tree,
knn_params.search_budget,
seed,
false,
verbose,
),
KnnSearch::NNDescent => generate_knn_nndescent(
embd,
&knn_params.ann_dist,
k,
knn_params.diversify_prob,
knn_params.ef_budget,
knn_params.delta,
seed,
false,
verbose,
),
KnnSearch::Exhaustive => generate_knn_exhaustive(embd, &knn_params.ann_dist, k, verbose),
KnnSearch::Ivf => generate_knn_ivf(
embd,
&knn_params.ann_dist,
k,
knn_params.n_list,
knn_params.n_list,
seed,
false,
verbose,
),
KnnSearch::KmKnn => generate_knn_kmknn(
embd,
&knn_params.ann_dist,
k,
knn_params.n_list,
seed,
verbose,
),
}
}
pub fn adjusted_k(base_k: usize, n_obs: usize, n_sim: usize) -> usize {
let k = if base_k == 0 {
((n_obs as f32).sqrt() * 0.5).round() as usize
} else {
base_k
};
let r = n_sim as f32 / n_obs as f32;
let adj_k = (k as f32 * (1.0 + r)).round() as usize;
adj_k.min(MAX_DOUBLET_K_NEIGHBOURS)
}
pub fn simulate_from_pairs<S: SingleCellReading>(
pairs: &[(usize, usize)],
cells_to_keep: &[usize],
hvg_library_sizes: &[usize],
hvg_genes: &[usize],
reader: &S,
target_size: f32,
log_transform: bool,
) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
let hvg_set: FxHashSet<usize> = hvg_genes.iter().copied().collect();
let gene_to_hvg_idx: FxHashMap<usize, u32> = hvg_genes
.iter()
.enumerate()
.map(|(hvg_idx, &orig_idx)| (orig_idx, hvg_idx as u32))
.collect();
pairs
.par_iter()
.enumerate()
.map(|(doublet_idx, &(pos_i, pos_j))| {
let cell1 = reader.read_cell(cells_to_keep[pos_i])?;
let cell2 = reader.read_cell(cells_to_keep[pos_j])?;
let hvg_combined_lib_size = hvg_library_sizes[pos_i] + hvg_library_sizes[pos_j];
let mut doublet = CsrCellChunk::add_cells_scrublet(
&cell1,
&cell2,
&hvg_set,
hvg_combined_lib_size,
target_size,
log_transform,
doublet_idx,
);
for idx in doublet.indices.iter_mut() {
*idx = gene_to_hvg_idx[&(*idx as usize)];
}
Ok(doublet)
})
.collect()
}
pub fn random_pairs(n_cells: usize, n_pairs: usize, seed: usize) -> Vec<(usize, usize)> {
use rand::prelude::*;
let mut rng = StdRng::seed_from_u64(seed as u64);
(0..n_pairs)
.map(|_| {
let i = rng.random_range(0..n_cells);
let j = rng.random_range(0..n_cells);
(i, j)
})
.collect()
}
pub fn find_threshold_otsu(scores_obs: &[f32], n_bins: usize) -> f32 {
let (max_score, min_score) = array_max_min(scores_obs);
if (max_score - min_score).abs() < 1e-6 {
return (min_score + max_score) / 2.0;
}
let bin_width = (max_score - min_score) / n_bins as f32;
let mut hist = vec![0usize; n_bins];
for &score in scores_obs {
let bin = ((score - min_score) / bin_width).floor() as usize;
hist[bin.min(n_bins - 1)] += 1;
}
let total = scores_obs.len() as f32;
let prob: Vec<f32> = hist.iter().map(|&c| c as f32 / total).collect();
let mut w0 = 0.0f32;
let mut sum0 = 0.0f32;
let total_mean: f32 = prob.iter().enumerate().map(|(i, &p)| i as f32 * p).sum();
let mut best_variance = 0.0f32;
let mut best_bin = 0usize;
for i in 0..n_bins {
w0 += prob[i];
if w0 < 1e-10 {
continue;
}
let w1 = 1.0 - w0;
if w1 < 1e-10 {
break;
}
sum0 += i as f32 * prob[i];
let mu0 = sum0 / w0;
let mu1 = (total_mean - sum0) / w1;
let between_var = w0 * w1 * (mu0 - mu1).powi(2);
if between_var > best_variance {
best_variance = between_var;
best_bin = i;
}
}
min_score + (best_bin as f32 + 0.5) * bin_width
}
fn select_by_overall_mean(
gene_chunks: &[CscGeneChunk],
n_cells: usize,
n_top: usize,
) -> Vec<usize> {
let nf = n_cells as f64;
let means: Vec<f64> = gene_chunks
.par_iter()
.map(|chunk| {
let sum: f64 = (0..chunk.indices.len())
.map(|i| chunk.data_raw.get(i) as f64)
.sum();
sum / nf
})
.collect();
top_n_indices(&means, n_top)
}
fn select_by_cluster_roundrobin(
gene_chunks: &[CscGeneChunk],
cluster_labels: &[usize],
n_top: usize,
) -> Vec<usize> {
let unique_clusters: Vec<usize> = {
let mut s: FxHashSet<usize> = FxHashSet::default();
for &c in cluster_labels {
s.insert(c);
}
let mut v: Vec<usize> = s.into_iter().collect();
v.sort_unstable();
v
};
let n_clusters = unique_clusters.len();
if n_clusters <= 1 {
return select_by_overall_mean(gene_chunks, cluster_labels.len(), n_top);
}
let cluster_idx: FxHashMap<usize, usize> = unique_clusters
.iter()
.enumerate()
.map(|(i, &c)| (c, i))
.collect();
let cluster_sums: Vec<Vec<f64>> = gene_chunks
.par_iter()
.map(|chunk| {
let mut sums = vec![0.0f64; n_clusters];
for i in 0..chunk.indices.len() {
let pos = chunk.indices[i] as usize;
let c_idx = cluster_idx[&cluster_labels[pos]];
sums[c_idx] += chunk.data_raw.get(i) as f64;
}
sums
})
.collect();
let n_genes = gene_chunks.len();
let per_cluster_rankings: Vec<Vec<usize>> = (0..n_clusters)
.into_par_iter()
.map(|c_idx| {
let col: Vec<f64> = (0..n_genes).map(|g| cluster_sums[g][c_idx]).collect();
top_n_indices_ordered(&col, n_top)
})
.collect();
let mut result = roundrobin_select(&per_cluster_rankings, n_top);
if result.len() < n_top {
let totals: Vec<f64> = cluster_sums.iter().map(|sums| sums.iter().sum()).collect();
let fallback = top_n_indices_ordered(&totals, gene_chunks.len());
let mut seen: FxHashSet<usize> = result.iter().copied().collect();
for g in fallback {
if seen.insert(g) {
result.push(g);
if result.len() >= n_top {
break;
}
}
}
}
result.sort_unstable();
result
}
pub fn select_top_genes_streaming<S: SingleCellReading>(
reader: &S,
cells_to_keep: &[usize],
clusters: Option<&[usize]>,
n_top: usize,
verbose: bool,
) -> Result<Vec<usize>, BixverseErrors> {
let n_total_genes = reader.get_header().total_genes;
if n_top >= n_total_genes {
return Ok((0..n_total_genes).collect());
}
if cells_to_keep.is_empty() {
return Ok((0..n_top).collect());
}
let cell_set: IndexSet<u32> = cells_to_keep.iter().map(|&x| x as u32).collect();
let cluster_setup: Option<(&[usize], FxHashMap<usize, usize>, usize)> = match clusters {
Some(labels) => {
assert_eq!(
labels.len(),
cells_to_keep.len(),
"cluster labels must be parallel to cells_to_keep",
);
let mut s: FxHashSet<usize> = FxHashSet::default();
for &c in labels {
s.insert(c);
}
if s.len() <= 1 {
None
} else {
let mut v: Vec<usize> = s.into_iter().collect();
v.sort_unstable();
let n_clusters = v.len();
let map: FxHashMap<usize, usize> =
v.iter().enumerate().map(|(i, &c)| (c, i)).collect();
Some((labels, map, n_clusters))
}
}
None => None,
};
let num_batches = n_total_genes.div_ceil(GENE_BATCH_SIZE);
let n_cells = cells_to_keep.len();
let mut means: Vec<f64> = Vec::new();
let mut cluster_sums: Vec<Vec<f64>> = Vec::new();
if cluster_setup.is_some() {
cluster_sums.reserve(n_total_genes);
} else {
means.reserve(n_total_genes);
}
for batch_idx in 0..num_batches {
let start = batch_idx * GENE_BATCH_SIZE;
let end = ((batch_idx + 1) * GENE_BATCH_SIZE).min(n_total_genes);
let gene_indices: Vec<usize> = (start..end).collect();
let mut gene_chunks = reader.read_gene_parallel(&gene_indices)?;
gene_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
if let Some((labels, cluster_idx, n_clusters)) = &cluster_setup {
let batch_sums: Vec<Vec<f64>> = gene_chunks
.par_iter()
.map(|chunk| {
let mut sums = vec![0.0f64; *n_clusters];
for i in 0..chunk.indices.len() {
let pos = chunk.indices[i] as usize;
let c_idx = cluster_idx[&labels[pos]];
sums[c_idx] += chunk.data_raw.get(i) as f64;
}
sums
})
.collect();
cluster_sums.extend(batch_sums);
} else {
let nf = n_cells as f64;
let batch_means: Vec<f64> = gene_chunks
.par_iter()
.map(|chunk| {
let sum: f64 = (0..chunk.indices.len())
.map(|i| chunk.data_raw.get(i) as f64)
.sum();
sum / nf
})
.collect();
means.extend(batch_means);
}
if verbose && batch_idx % 5 == 0 {
let progress = (batch_idx + 1) as f32 / num_batches as f32 * 100.0;
println!(" Progress: {:.1}%", progress);
}
drop(gene_chunks);
}
match cluster_setup {
None => Ok(top_n_indices(&means, n_top)),
Some((_, _, n_clusters)) => {
let n_genes = cluster_sums.len();
let per_cluster_rankings: Vec<Vec<usize>> = (0..n_clusters)
.into_par_iter()
.map(|c_idx| {
let col: Vec<f64> = (0..n_genes).map(|g| cluster_sums[g][c_idx]).collect();
top_n_indices_ordered(&col, n_top)
})
.collect();
let mut result = roundrobin_select(&per_cluster_rankings, n_top);
if result.len() < n_top {
let totals: Vec<f64> = cluster_sums.iter().map(|sums| sums.iter().sum()).collect();
let fallback = top_n_indices_ordered(&totals, n_genes);
let mut seen: FxHashSet<usize> = result.iter().copied().collect();
for g in fallback {
if seen.insert(g) {
result.push(g);
if result.len() >= n_top {
break;
}
}
}
}
result.sort_unstable();
Ok(result)
}
}
}
fn roundrobin_select(per_cluster_rankings: &[Vec<usize>], n_top: usize) -> Vec<usize> {
let n_clusters = per_cluster_rankings.len();
let max_rank = per_cluster_rankings
.iter()
.map(|v| v.len())
.max()
.unwrap_or(0);
let mut seen: FxHashSet<usize> = FxHashSet::default();
let mut result: Vec<usize> = Vec::with_capacity(n_top);
for rank in 0..max_rank {
for c_idx in 0..n_clusters {
if rank < per_cluster_rankings[c_idx].len() {
let g = per_cluster_rankings[c_idx][rank];
if seen.insert(g) {
result.push(g);
if result.len() >= n_top {
return result;
}
}
}
}
}
result
}
fn top_n_indices(values: &[f64], n_top: usize) -> Vec<usize> {
let mut result = top_n_indices_ordered(values, n_top);
result.sort_unstable();
result
}
fn top_n_indices_ordered(values: &[f64], n_top: usize) -> Vec<usize> {
let n = values.len();
let k = n_top.min(n);
let mut indexed: Vec<(usize, f64)> = values.iter().copied().enumerate().collect();
indexed.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
indexed.into_iter().take(k).map(|(i, _)| i).collect()
}
pub fn select_top_genes<S: SingleCellReading>(
reader: &S,
cells_to_keep: &[usize],
clusters: Option<&[usize]>,
n_top: usize,
) -> Result<Vec<usize>, BixverseErrors> {
let n_total_genes = reader.get_header().total_genes;
if n_top >= n_total_genes {
return Ok((0..n_total_genes).collect());
}
if cells_to_keep.is_empty() {
return Ok((0..n_top).collect());
}
let cell_set: IndexSet<u32> = cells_to_keep.iter().map(|&x| x as u32).collect();
let all_indices: Vec<usize> = (0..n_total_genes).collect();
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(&all_indices)?;
gene_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
match clusters {
None => Ok(select_by_overall_mean(
&gene_chunks,
cells_to_keep.len(),
n_top,
)),
Some(labels) => {
assert_eq!(
labels.len(),
cells_to_keep.len(),
"cluster labels must be parallel to cells_to_keep",
);
Ok(select_by_cluster_roundrobin(&gene_chunks, labels, n_top))
}
}
}
#[derive(Clone, Debug)]
pub struct ScDblSimParams {
pub resamp_frac: f32,
pub half_size_frac: f32,
pub adjust_size_frac: f32,
}
impl Default for ScDblSimParams {
fn default() -> Self {
Self {
resamp_frac: 0.25,
half_size_frac: 0.25,
adjust_size_frac: 0.25,
}
}
}
#[derive(Clone, Debug)]
pub struct DoubletTreatment {
adjust_size: bool,
half_size: bool,
resamp: bool,
rng_seed: u64,
}
fn poisson_sample(rng: &mut impl Rng, lambda: f64) -> u32 {
if lambda <= 0.0 {
return 0;
}
if lambda > 500.0 {
let u1: f64 = rng.random::<f64>().max(1e-300);
let u2: f64 = rng.random::<f64>();
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
let val = lambda + lambda.sqrt() * z;
return val.round().max(0.0) as u32;
}
let l = (-lambda).exp();
let mut k = 0u32;
let mut p = 1.0f64;
loop {
p *= rng.random::<f64>();
if p < l {
return k;
}
k += 1;
}
}
fn compute_cluster_median_lib_sizes(
cluster_labels: &[usize],
selected_genes_library_sizes: &[usize],
) -> FxHashMap<usize, f64> {
let mut per_cluster: FxHashMap<usize, Vec<f64>> = FxHashMap::default();
for (i, &cl) in cluster_labels.iter().enumerate() {
per_cluster
.entry(cl)
.or_default()
.push(selected_genes_library_sizes[i] as f64);
}
per_cluster
.into_iter()
.map(|(cl, mut sizes)| {
sizes.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let med = if sizes.len() % 2 == 0 {
(sizes[sizes.len() / 2 - 1] + sizes[sizes.len() / 2]) / 2.0
} else {
sizes[sizes.len() / 2]
};
(cl, med.max(1.0))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn generate_single_doublet(
parent_a: &[u32],
parent_b: &[u32],
lib_a: usize,
lib_b: usize,
cluster_a: usize,
cluster_b: usize,
cluster_medians: &FxHashMap<usize, f64>,
treatment: &DoubletTreatment,
) -> (Vec<u32>, usize) {
let n_genes = parent_a.len();
debug_assert_eq!(parent_b.len(), n_genes);
let combined_lib = (lib_a + lib_b) as f64;
let mut counts_f64 = vec![0.0f64; n_genes];
if treatment.adjust_size {
let actual_ratio = lib_a as f64 / combined_lib;
let med_a = cluster_medians.get(&cluster_a).copied().unwrap_or(1.0);
let med_b = cluster_medians.get(&cluster_b).copied().unwrap_or(1.0);
let cluster_ratio = med_a / (med_a + med_b);
let factor = ((actual_ratio + cluster_ratio) / 2.0).clamp(0.2, 0.8);
for g in 0..n_genes {
counts_f64[g] = parent_a[g] as f64 * factor + parent_b[g] as f64 * (1.0 - factor);
}
let raw_sum: f64 = counts_f64.iter().sum();
if raw_sum > 0.0 {
let scale = combined_lib / raw_sum;
for v in counts_f64.iter_mut() {
*v *= scale;
}
}
} else {
for g in 0..n_genes {
counts_f64[g] = parent_a[g] as f64 + parent_b[g] as f64;
}
}
if treatment.half_size {
for v in counts_f64.iter_mut() {
*v *= 0.5;
}
}
let mut rng = StdRng::seed_from_u64(treatment.rng_seed);
let counts: Vec<u32> = if treatment.resamp {
counts_f64
.iter()
.map(|&v| poisson_sample(&mut rng, v))
.collect()
} else {
counts_f64
.iter()
.map(|&v| v.round().max(0.0) as u32)
.collect()
};
let effective_lib: usize = counts.iter().map(|&c| c as usize).sum();
(counts, effective_lib)
}
impl CsrCellChunk {
pub fn from_doublet_simulation(
indices: Vec<u32>,
raw_counts: Vec<u32>,
norm_values: Vec<f32>,
effective_lib_size: usize,
doublet_index: usize,
) -> Self {
debug_assert_eq!(indices.len(), raw_counts.len());
debug_assert_eq!(indices.len(), norm_values.len());
let data_raw = RawCounts::from_u32_auto(&raw_counts);
let data_norm: Vec<F16> = norm_values
.iter()
.map(|&v| F16::from(f16::from_f32(v)))
.collect();
Self {
data_raw,
data_norm,
library_size: effective_lib_size,
indices,
original_index: doublet_index,
to_keep: true,
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn simulate_doublets_scdbl<S: SingleCellReading>(
pairs: &[(usize, usize)],
cells_to_keep: &[usize],
selected_genes_library_sizes: &[usize],
cluster_labels: &[usize],
selected_genes: &[usize],
reader: &S,
target_size: f32,
log_transform: bool,
params: &ScDblSimParams,
seed: usize,
) -> Result<(Vec<CsrCellChunk>, Vec<usize>), BixverseErrors> {
let n_sim = pairs.len();
let n_hvg = selected_genes.len();
let gene_to_hvg_idx: FxHashMap<usize, usize> = selected_genes
.iter()
.enumerate()
.map(|(pos, &orig)| (orig, pos))
.collect();
let cluster_medians =
compute_cluster_median_lib_sizes(cluster_labels, selected_genes_library_sizes);
let mut master_rng = StdRng::seed_from_u64(seed as u64 + 0xDEAD);
let treatments: Vec<DoubletTreatment> = (0..n_sim)
.map(|_| DoubletTreatment {
adjust_size: master_rng.random::<f32>() < params.adjust_size_frac,
half_size: master_rng.random::<f32>() < params.half_size_frac,
resamp: master_rng.random::<f32>() < params.resamp_frac,
rng_seed: master_rng.next_u64(),
})
.collect();
let results: Vec<(CsrCellChunk, usize)> = (0..n_sim)
.into_par_iter()
.map(|di| -> Result<(CsrCellChunk, usize), BixverseErrors> {
let (pos_a, pos_b) = pairs[di];
let cell_a = reader.read_cell(cells_to_keep[pos_a])?;
let cell_b = reader.read_cell(cells_to_keep[pos_b])?;
let mut dense_a = vec![0u32; n_hvg];
let mut dense_b = vec![0u32; n_hvg];
for (i, &gene_idx) in cell_a.indices.iter().enumerate() {
let gi = gene_idx as usize;
if let Some(&hvg_pos) = gene_to_hvg_idx.get(&gi) {
dense_a[hvg_pos] = cell_a.data_raw.get(i);
}
}
for (i, &gene_idx) in cell_b.indices.iter().enumerate() {
let gi = gene_idx as usize;
if let Some(&hvg_pos) = gene_to_hvg_idx.get(&gi) {
dense_b[hvg_pos] = cell_b.data_raw.get(i);
}
}
let (counts, effective_lib) = generate_single_doublet(
&dense_a,
&dense_b,
selected_genes_library_sizes[pos_a],
selected_genes_library_sizes[pos_b],
cluster_labels[pos_a],
cluster_labels[pos_b],
&cluster_medians,
&treatments[di],
);
let lib_f32 = (effective_lib as f32).max(1.0);
let mut sp_indices: Vec<u32> = Vec::new();
let mut sp_raw: Vec<u32> = Vec::new();
let mut sp_norm: Vec<f32> = Vec::new();
for (g, &c) in counts.iter().enumerate() {
if c > 0 {
sp_indices.push(g as u32);
sp_raw.push(c);
let normed = if log_transform {
((c as f32 / lib_f32) * target_size).ln_1p()
} else {
(c as f32 / lib_f32) * target_size
};
sp_norm.push(normed);
}
}
let chunk = CsrCellChunk::from_doublet_simulation(
sp_indices,
sp_raw,
sp_norm,
effective_lib,
di,
);
Ok((chunk, effective_lib))
})
.collect::<Result<Vec<_>, _>>()?;
let mut chunks = Vec::with_capacity(n_sim);
let mut lib_sizes = Vec::with_capacity(n_sim);
for (chunk, lib) in results {
chunks.push(chunk);
lib_sizes.push(lib);
}
Ok((chunks, lib_sizes))
}
#[derive(Clone, Debug)]
pub struct UnrecognisableFilterParams {
pub min_size: usize,
pub min_med_diff: f32,
}
impl Default for UnrecognisableFilterParams {
fn default() -> Self {
Self {
min_size: 5,
min_med_diff: 0.1,
}
}
}
#[inline]
pub fn canonical_pair(a: usize, b: usize) -> Option<(usize, usize)> {
if a == b {
None
} else {
Some((a.min(b), a.max(b)))
}
}
fn quantile_sorted(sorted: &[f32], p: f32) -> f32 {
if sorted.is_empty() {
return f32::NAN;
}
if sorted.len() == 1 {
return sorted[0];
}
let idx = p.clamp(0.0, 1.0) * (sorted.len() - 1) as f32;
let lo = idx.floor() as usize;
let hi = (lo + 1).min(sorted.len() - 1);
let frac = idx - lo as f32;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
pub fn identify_unrecognisable_origins(
obs_scores: &[f32],
obs_cluster_labels: &[usize],
sim_scores: &[f32],
sim_parent_clusters: &[(usize, usize)],
params: &UnrecognisableFilterParams,
) -> FxHashSet<(usize, usize)> {
assert_eq!(obs_scores.len(), obs_cluster_labels.len());
assert_eq!(sim_scores.len(), sim_parent_clusters.len());
let global_median = {
let mut sorted: Vec<f32> = obs_scores.to_vec();
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
quantile_sorted(&sorted, 0.5)
};
let mut per_cluster: FxHashMap<usize, Vec<f32>> = FxHashMap::default();
for (i, &cl) in obs_cluster_labels.iter().enumerate() {
per_cluster.entry(cl).or_default().push(obs_scores[i]);
}
let mut cluster_pcts: FxHashMap<usize, (f32, f32)> = FxHashMap::default();
for (cl, mut scores) in per_cluster {
scores.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
cluster_pcts.insert(
cl,
(quantile_sorted(&scores, 0.5), quantile_sorted(&scores, 0.9)),
);
}
let mut sims_by_origin: FxHashMap<(usize, usize), Vec<f32>> = FxHashMap::default();
for (si, &(ca, cb)) in sim_parent_clusters.iter().enumerate() {
if let Some(canon) = canonical_pair(ca, cb) {
sims_by_origin
.entry(canon)
.or_default()
.push(sim_scores[si]);
}
}
let mut flagged: FxHashSet<(usize, usize)> = FxHashSet::default();
for (origin, mut scores) in sims_by_origin {
if scores.len() < params.min_size {
continue;
}
scores.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let sim_p10 = quantile_sorted(&scores, 0.1);
let sim_p50 = quantile_sorted(&scores, 0.5);
let fallback = (f32::NEG_INFINITY, f32::NEG_INFINITY);
let (p50_a, p90_a) = cluster_pcts.get(&origin.0).copied().unwrap_or(fallback);
let (p50_b, p90_b) = cluster_pcts.get(&origin.1).copied().unwrap_or(fallback);
let cond_a = sim_p10 < p90_a.max(p90_b);
let max_reference = global_median.max(p50_a).max(p50_b);
let cond_b = (sim_p50 - max_reference) < params.min_med_diff;
if cond_a || cond_b {
flagged.insert(origin);
}
}
flagged
}
pub fn mark_sims_from_flagged_origins(
sim_parent_clusters: &[(usize, usize)],
flagged: &FxHashSet<(usize, usize)>,
) -> Vec<bool> {
sim_parent_clusters
.iter()
.map(|&(ca, cb)| {
canonical_pair(ca, cb)
.map(|c| flagged.contains(&c))
.unwrap_or(false)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_top_n_ordered_preserves_rank_order() {
let values = vec![1.0, 5.0, 3.0, 2.0, 4.0];
assert_eq!(top_n_indices_ordered(&values, 3), vec![1, 4, 2]);
}
#[test]
fn test_top_n_indices_sorts_by_index() {
let values = vec![1.0, 5.0, 3.0, 2.0, 4.0];
assert_eq!(top_n_indices(&values, 3), vec![1, 2, 4]);
}
#[test]
fn test_top_n_ordered_ties_prefer_lower_index() {
let values = vec![5.0, 5.0, 5.0, 1.0];
assert_eq!(top_n_indices_ordered(&values, 2), vec![0, 1]);
}
#[test]
fn test_roundrobin_basic() {
let rankings = vec![vec![10, 20, 30], vec![11, 21, 31], vec![12, 22, 32]];
let result = roundrobin_select(&rankings, 5);
assert_eq!(result, vec![10, 11, 12, 20, 21]);
}
#[test]
fn test_roundrobin_dedupe() {
let rankings = vec![
vec![1, 2, 3, 4],
vec![1, 3, 5, 7], vec![2, 5, 8, 9], ];
let result = roundrobin_select(&rankings, 5);
assert_eq!(result, vec![1, 2, 3, 5, 8]);
}
#[test]
fn test_roundrobin_stops_at_n_top() {
let rankings = vec![vec![1, 2, 3], vec![4, 5, 6]];
let result = roundrobin_select(&rankings, 3);
assert_eq!(result, vec![1, 4, 2]);
assert_eq!(result.len(), 3);
}
#[test]
fn test_roundrobin_small_per_cluster_lists() {
let rankings = vec![vec![1, 2], vec![3], vec![4, 5, 6]];
let result = roundrobin_select(&rankings, 10);
assert_eq!(result, vec![1, 3, 4, 2, 5, 6]);
}
#[test]
fn test_roundrobin_insufficient_unique() {
let rankings = vec![vec![1, 2, 3], vec![1, 2, 3], vec![1, 2, 3]];
let result = roundrobin_select(&rankings, 10);
assert_eq!(result, vec![1, 2, 3]); }
#[test]
fn test_roundrobin_empty() {
let rankings: Vec<Vec<usize>> = vec![];
let result = roundrobin_select(&rankings, 5);
assert_eq!(result, Vec::<usize>::new());
}
#[test]
fn test_poisson_sampler_statistics() {
let mut rng = StdRng::seed_from_u64(42);
for &lambda in &[0.5, 2.0, 10.0, 50.0, 200.0, 1000.0] {
let n = 5_000;
let samples: Vec<u32> = (0..n).map(|_| poisson_sample(&mut rng, lambda)).collect();
let mean = samples.iter().map(|&s| s as f64).sum::<f64>() / n as f64;
let var = samples
.iter()
.map(|&s| {
let d = s as f64 - mean;
d * d
})
.sum::<f64>()
/ n as f64;
let tol = 3.0 * (lambda / n as f64).sqrt(); assert!(
(mean - lambda).abs() < lambda * 0.05 + tol,
"lambda={}: mean={:.2}, expected={:.2}",
lambda,
mean,
lambda
);
assert!(
(var - lambda).abs() < lambda * 0.1 + tol * lambda,
"lambda={}: var={:.2}, expected={:.2}",
lambda,
var,
lambda
);
}
}
#[test]
fn test_poisson_sampler_zero() {
let mut rng = StdRng::seed_from_u64(99);
for _ in 0..100 {
assert_eq!(poisson_sample(&mut rng, 0.0), 0);
assert_eq!(poisson_sample(&mut rng, -1.0), 0);
}
}
#[test]
fn test_cluster_median_lib_sizes() {
let clusters = vec![0, 0, 0, 1, 1, 2];
let lib_sizes = vec![100, 200, 300, 50, 150, 500];
let medians = compute_cluster_median_lib_sizes(&clusters, &lib_sizes);
assert!((medians[&0] - 200.0).abs() < 1e-6); assert!((medians[&1] - 100.0).abs() < 1e-6); assert!((medians[&2] - 500.0).abs() < 1e-6); }
#[test]
fn test_no_treatment_is_simple_sum() {
let parent_a = vec![0, 5, 10, 0, 3];
let parent_b = vec![2, 0, 7, 1, 0];
let cluster_medians = FxHashMap::default();
let treatment = DoubletTreatment {
adjust_size: false,
half_size: false,
resamp: false,
rng_seed: 0,
};
let (counts, lib) = generate_single_doublet(
&parent_a,
&parent_b,
18,
10,
0,
1,
&cluster_medians,
&treatment,
);
assert_eq!(counts, vec![2, 5, 17, 1, 3]);
assert_eq!(lib, 28);
}
#[test]
fn test_half_size_halves_library() {
let parent_a = vec![10, 20, 30, 40];
let parent_b = vec![10, 20, 30, 40];
let cluster_medians = FxHashMap::default();
let no_half = DoubletTreatment {
adjust_size: false,
half_size: false,
resamp: false,
rng_seed: 0,
};
let (_, lib_full) = generate_single_doublet(
&parent_a,
&parent_b,
100,
100,
0,
0,
&cluster_medians,
&no_half,
);
let with_half = DoubletTreatment {
adjust_size: false,
half_size: true,
resamp: false,
rng_seed: 0,
};
let (_, lib_half) = generate_single_doublet(
&parent_a,
&parent_b,
100,
100,
0,
0,
&cluster_medians,
&with_half,
);
assert_eq!(lib_full, 200);
assert_eq!(lib_half, 100); }
#[test]
fn test_resamp_adds_noise() {
let parent_a = vec![10, 20, 30, 40, 50];
let parent_b = vec![5, 15, 25, 35, 45];
let cluster_medians = FxHashMap::default();
let mut all_equal = true;
let mut first: Option<Vec<u32>> = None;
for seed in 0..20u64 {
let treatment = DoubletTreatment {
adjust_size: false,
half_size: false,
resamp: true,
rng_seed: seed * 7919 + 42,
};
let (counts, _) = generate_single_doublet(
&parent_a,
&parent_b,
135,
125,
0,
0,
&cluster_medians,
&treatment,
);
if let Some(ref f) = first {
if &counts != f {
all_equal = false;
break;
}
} else {
first = Some(counts);
}
}
assert!(
!all_equal,
"Poisson resampling should produce different counts across seeds"
);
}
#[test]
fn test_resamp_preserves_mean() {
let parent_a = vec![0, 10, 50, 100];
let parent_b = vec![5, 10, 50, 100];
let expected_sum = [5, 20, 100, 200];
let cluster_medians = FxHashMap::default();
let n_reps = 5000;
let mut accum = [0.0f64; 4];
for rep in 0..n_reps {
let treatment = DoubletTreatment {
adjust_size: false,
half_size: false,
resamp: true,
rng_seed: rep as u64 * 31 + 7,
};
let (counts, _) = generate_single_doublet(
&parent_a,
&parent_b,
160,
165,
0,
0,
&cluster_medians,
&treatment,
);
for (i, &c) in counts.iter().enumerate() {
accum[i] += c as f64;
}
}
for i in 0..4 {
let mean = accum[i] / n_reps as f64;
let expected = expected_sum[i] as f64;
let tol = 3.0 * (expected / n_reps as f64).sqrt().max(0.5);
assert!(
(mean - expected).abs() < expected * 0.05 + tol,
"gene {}: mean={:.2}, expected={:.2}",
i,
mean,
expected
);
}
}
#[test]
fn test_adjust_size_preserves_total_library() {
let parent_a = vec![10, 20, 30, 40];
let parent_b = vec![40, 30, 20, 10];
let lib_a = 100;
let lib_b = 100;
let mut cluster_medians = FxHashMap::default();
cluster_medians.insert(0, 200.0);
cluster_medians.insert(1, 100.0);
let treatment = DoubletTreatment {
adjust_size: true,
half_size: false,
resamp: false,
rng_seed: 0,
};
let (counts, lib) = generate_single_doublet(
&parent_a,
&parent_b,
lib_a,
lib_b,
0,
1,
&cluster_medians,
&treatment,
);
assert_eq!(lib, lib_a + lib_b);
let simple_sum: Vec<u32> = parent_a
.iter()
.zip(&parent_b)
.map(|(&a, &b)| a + b)
.collect();
assert_ne!(
counts, simple_sum,
"adjusted counts should differ from simple sum"
);
}
#[test]
fn test_adjust_size_clamping() {
let parent_a = vec![100, 0, 0, 0];
let parent_b = vec![0, 0, 0, 10000];
let mut cluster_medians = FxHashMap::default();
cluster_medians.insert(0, 1.0);
cluster_medians.insert(1, 10000.0);
let treatment = DoubletTreatment {
adjust_size: true,
half_size: false,
resamp: false,
rng_seed: 0,
};
let (counts, lib) = generate_single_doublet(
&parent_a,
&parent_b,
100,
10000,
0,
1,
&cluster_medians,
&treatment,
);
assert_eq!(lib, 10100);
assert!(
counts[0] > 10,
"clamping should give parent_a at least 20% weight; gene 0 got {}",
counts[0]
);
assert!(
counts[3] > counts[0],
"gene 3 ({}) should still dominate gene 0 ({})",
counts[3],
counts[0]
);
}
#[test]
fn test_library_size_distribution_has_variance() {
let parent = vec![10, 20, 30, 40, 50]; let cluster_medians = FxHashMap::default();
let params = ScDblSimParams::default();
let n = 1000;
let mut master_rng = StdRng::seed_from_u64(123);
let treatments: Vec<DoubletTreatment> = (0..n)
.map(|_| DoubletTreatment {
adjust_size: master_rng.random::<f32>() < params.adjust_size_frac,
half_size: master_rng.random::<f32>() < params.half_size_frac,
resamp: master_rng.random::<f32>() < params.resamp_frac,
rng_seed: master_rng.next_u64(),
})
.collect();
let lib_sizes: Vec<usize> = treatments
.iter()
.map(|t| {
let (_, lib) =
generate_single_doublet(&parent, &parent, 150, 150, 0, 0, &cluster_medians, t);
lib
})
.collect();
let mean = lib_sizes.iter().sum::<usize>() as f64 / n as f64;
let var = lib_sizes
.iter()
.map(|&l| {
let d = l as f64 - mean;
d * d
})
.sum::<f64>()
/ n as f64;
let n_at_300 = lib_sizes.iter().filter(|&&l| l == 300).count();
assert!(
n_at_300 < n * 9 / 10,
"expected fewer than 90% at exactly 2x; got {}/{} at 300",
n_at_300,
n
);
assert!(
var > 100.0,
"expected substantial variance in lib sizes; got var={:.1}",
var
);
}
#[test]
fn test_deterministic_with_same_seed() {
let parent_a = vec![5, 10, 15, 20];
let parent_b = vec![8, 12, 3, 25];
let cluster_medians = FxHashMap::default();
let treatment = DoubletTreatment {
adjust_size: false,
half_size: true,
resamp: true,
rng_seed: 42,
};
let (c1, l1) = generate_single_doublet(
&parent_a,
&parent_b,
50,
48,
0,
1,
&cluster_medians,
&treatment,
);
let (c2, l2) = generate_single_doublet(
&parent_a,
&parent_b,
50,
48,
0,
1,
&cluster_medians,
&treatment,
);
assert_eq!(c1, c2);
assert_eq!(l1, l2);
}
#[test]
fn test_quantile_basic() {
let s = vec![0.1, 0.2, 0.3, 0.4, 0.5];
assert!((quantile_sorted(&s, 0.0) - 0.1).abs() < 1e-6);
assert!((quantile_sorted(&s, 0.5) - 0.3).abs() < 1e-6);
assert!((quantile_sorted(&s, 1.0) - 0.5).abs() < 1e-6);
}
#[test]
fn test_quantile_interpolation() {
let s = vec![0.0, 10.0];
assert!((quantile_sorted(&s, 0.5) - 5.0).abs() < 1e-6);
assert!((quantile_sorted(&s, 0.1) - 1.0).abs() < 1e-6);
}
#[test]
fn test_quantile_empty_and_single() {
assert!(quantile_sorted(&[], 0.5).is_nan());
assert_eq!(quantile_sorted(&[3.15], 0.5), 3.15);
}
#[test]
fn test_canonical_pair_ordering() {
assert_eq!(canonical_pair(2, 5), Some((2, 5)));
assert_eq!(canonical_pair(5, 2), Some((2, 5)));
assert_eq!(canonical_pair(3, 3), None);
}
#[test]
fn test_too_few_sims_not_flagged() {
let obs = vec![0.1; 10];
let clust: Vec<usize> = (0..10).map(|i| i % 2).collect();
let sim = vec![0.1, 0.15, 0.2, 0.25];
let sp = vec![(0, 1); 4];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.is_empty());
}
#[test]
fn test_clear_separation_not_flagged() {
let obs = vec![0.1; 20];
let clust: Vec<usize> = (0..20).map(|i| i % 2).collect();
let sim = vec![0.9, 0.92, 0.95, 0.97, 0.99, 0.88, 0.91, 0.94];
let sp = vec![(0, 1); 8];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.is_empty());
}
#[test]
fn test_cond_a_triggers_via_any_parent() {
let obs = vec![
0.6, 0.7, 0.8, 0.9, 1.0, 0.1, 0.1, 0.1, 0.1, 0.1, ];
let clust = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
let sim = vec![0.5, 0.6, 0.65, 0.7, 0.75, 0.6, 0.65, 0.7];
let sp = vec![(0, 1); 8];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.contains(&(0, 1)));
}
#[test]
fn test_cond_b_triggers_on_median_proximity() {
let obs = vec![0.1, 0.1, 0.1, 0.1, 0.1, 0.15, 0.15, 0.15, 0.15, 0.15];
let clust = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
let sim = vec![0.20, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27];
let sp = vec![(0, 1); 8];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.contains(&(0, 1)));
}
#[test]
fn test_canonical_combines_reversed_pairs() {
let obs = vec![0.1, 0.1, 0.1, 0.1, 0.1, 0.15, 0.15, 0.15, 0.15, 0.15];
let clust = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
let sim = vec![0.20, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27];
let sp = vec![
(0, 1),
(1, 0),
(0, 1),
(1, 0),
(0, 1),
(1, 0),
(0, 1),
(1, 0),
];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.contains(&(0, 1)));
}
#[test]
fn test_homotypic_ignored() {
let obs = vec![0.1; 10];
let clust = vec![0; 10];
let sim = vec![0.1; 8];
let sp = vec![(0, 0); 8];
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.is_empty());
}
#[test]
fn test_mixed_flagged_and_kept() {
let obs = vec![
0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.6, 0.7, 0.8, 0.9, 1.0, 0.1, 0.1, 0.1, 0.1, 0.1, ];
let clust = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3];
let sim = vec![
0.90, 0.92, 0.95, 0.97, 0.99, 0.91, 0.93, 0.96, 0.50, 0.60, 0.65, 0.70, 0.75, 0.60,
0.65, 0.70,
];
let sp: Vec<(usize, usize)> = std::iter::repeat_n((0, 1), 8)
.chain(std::iter::repeat_n((2, 3), 8))
.collect();
let flagged = identify_unrecognisable_origins(&obs, &clust, &sim, &sp, &Default::default());
assert!(flagged.contains(&(2, 3)));
assert!(!flagged.contains(&(0, 1)));
}
#[test]
fn test_mark_sims_from_flagged_origins_canonical() {
let sp = vec![(0, 1), (2, 3), (1, 0), (3, 2), (5, 5)];
let mut flagged = FxHashSet::default();
flagged.insert((0, 1));
let mask = mark_sims_from_flagged_origins(&sp, &flagged);
assert_eq!(mask, vec![true, false, true, false, false]);
}
#[test]
fn test_empty_inputs() {
let flagged = identify_unrecognisable_origins(&[], &[], &[], &[], &Default::default());
assert!(flagged.is_empty());
}
}