use clap::{Args, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClusterStrategy {
Legacy,
EditMass,
}
#[derive(Debug, Clone, Args)]
pub struct ClusteringSettings {
#[arg(
long = "cluster-strategy",
value_enum,
default_value_t = ClusterStrategy::Legacy
)]
pub strategy: ClusterStrategy,
#[arg(long = "cluster-min-records", value_name = "NUM")]
pub min_records: Option<usize>,
#[arg(long = "cluster-min-mass", value_name = "NUM")]
pub min_mass: Option<f64>,
#[arg(long = "cluster-max-gap", value_name = "BASES", default_value_t = 20)]
pub max_gap: usize,
#[arg(
long = "cluster-min-density",
value_name = "NUM",
default_value_t = 0.2
)]
pub min_density: f64,
}
impl Default for ClusteringSettings {
fn default() -> Self {
Self {
strategy: ClusterStrategy::Legacy,
min_records: None,
min_mass: None,
max_gap: 20,
min_density: 0.2,
}
}
}
impl ClusteringSettings {
pub fn validate(&self) -> anyhow::Result<()> {
if self.min_mass.is_some() && self.strategy != ClusterStrategy::EditMass {
anyhow::bail!("--cluster-min-mass is only valid with --cluster-strategy edit-mass");
}
Ok(())
}
fn min_records(&self) -> usize {
self.min_records.unwrap_or(match self.strategy {
ClusterStrategy::Legacy => 2,
ClusterStrategy::EditMass => 1,
})
}
fn min_mass(&self) -> f64 {
self.min_mass.unwrap_or(2.0)
}
pub fn event_mass(&self, ref_len: usize, alt_len: usize) -> f64 {
match self.strategy {
ClusterStrategy::Legacy => variant_complexity(ref_len, alt_len),
ClusterStrategy::EditMass => edit_len(ref_len, alt_len),
}
}
pub fn span(&self, ref_extent: f64, query_extent: f64) -> f64 {
match self.strategy {
ClusterStrategy::Legacy => ref_extent,
ClusterStrategy::EditMass => ref_extent.max(query_extent),
}
}
pub fn is_valid_cluster(&self, mass: f64, span: f64, count: usize) -> bool {
if count < 1.max(self.min_records()) {
return false;
}
if self.strategy == ClusterStrategy::EditMass && mass < self.min_mass() {
return false;
}
mass / span.max(1.0) >= self.min_density
}
}
#[allow(clippy::cast_precision_loss)]
pub fn variant_complexity(ref_len: usize, alt_len: usize) -> f64 {
match (ref_len, alt_len) {
(1, 1) => 1.0,
(n, m) => 1.0 + ((n.max(m) - 1) as f64).log2(),
}
}
#[allow(clippy::cast_precision_loss)]
pub fn edit_len(ref_len: usize, alt_len: usize) -> f64 {
if ref_len == alt_len {
ref_len as f64
} else {
(ref_len.max(alt_len) - 1) as f64
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn snp_edit_len_is_one() {
assert_eq!(edit_len(1, 1), 1.0);
}
#[test]
fn mnv_edit_len_is_block_length() {
assert_eq!(edit_len(4, 4), 4.0);
}
#[test]
fn insertion_edit_len() {
assert_eq!(edit_len(1, 2), 1.0);
assert_eq!(edit_len(1, 3), 2.0);
assert_eq!(edit_len(1, 5), 4.0);
}
#[test]
fn deletion_edit_len_mirrors_insertion() {
assert_eq!(edit_len(2, 1), edit_len(1, 2));
assert_eq!(edit_len(3, 1), edit_len(1, 3));
}
#[test]
fn snp_variant_complexity_is_one() {
assert_eq!(variant_complexity(1, 1), 1.0);
}
#[test]
fn indel_variant_complexity_is_log_scaled() {
assert_eq!(variant_complexity(1, 3), 2.0);
assert_eq!(variant_complexity(3, 1), 2.0);
}
#[test]
fn valid_cluster_at_density_threshold() {
let s = ClusteringSettings {
strategy: ClusterStrategy::EditMass,
min_records: Some(1),
min_mass: Some(2.0),
max_gap: 20,
min_density: 0.2,
};
assert!(s.is_valid_cluster(2.0, 10.0, 2));
}
#[test]
fn cluster_rejected_below_density_threshold() {
let s = ClusteringSettings {
strategy: ClusterStrategy::EditMass,
min_records: Some(1),
min_mass: Some(0.0),
max_gap: 20,
min_density: 0.2,
};
assert!(!s.is_valid_cluster(1.9, 10.0, 2));
}
#[test]
fn cluster_rejected_below_min_mass() {
let s = ClusteringSettings {
strategy: ClusterStrategy::EditMass,
min_records: Some(1),
min_mass: Some(2.0),
max_gap: 20,
min_density: 0.0,
};
assert!(!s.is_valid_cluster(1.0, 1.0, 1));
assert!(s.is_valid_cluster(5.0, 6.0, 1));
}
#[test]
fn legacy_has_no_min_mass_gate() {
let s = ClusteringSettings {
strategy: ClusterStrategy::Legacy,
min_records: None,
min_mass: None,
max_gap: 20,
min_density: 0.0,
};
assert!(!s.is_valid_cluster(1.0, 1.0, 1));
assert!(s.is_valid_cluster(1.0, 1.0, 2));
}
#[test]
fn cluster_rejected_below_min_records() {
let s = ClusteringSettings {
strategy: ClusterStrategy::EditMass,
min_records: Some(3),
min_mass: Some(0.0),
max_gap: 20,
min_density: 0.0,
};
assert!(!s.is_valid_cluster(10.0, 5.0, 2));
assert!(s.is_valid_cluster(10.0, 5.0, 3));
}
#[test]
fn validate_rejects_min_mass_without_edit_mass() {
let s = ClusteringSettings {
strategy: ClusterStrategy::Legacy,
min_mass: Some(2.0),
..Default::default()
};
assert!(s.validate().is_err());
let s = ClusteringSettings {
strategy: ClusterStrategy::EditMass,
min_mass: Some(2.0),
..Default::default()
};
assert!(s.validate().is_ok());
}
}