use chematic_core::Molecule;
use chematic_fp::ecfp4;
#[derive(Debug, Clone, PartialEq)]
pub struct ActivityCliff {
pub mol_a_idx: usize,
pub mol_b_idx: usize,
pub similarity: f32,
pub activity_delta: f64,
}
pub fn activity_cliffs(
mols: &[&Molecule],
activities: &[f64],
sim_threshold: f32,
cliff_delta: f64,
) -> Vec<ActivityCliff> {
assert_eq!(
mols.len(),
activities.len(),
"mols and activities must have the same length"
);
let n = mols.len();
if n < 2 {
return Vec::new();
}
let fps: Vec<_> = mols.iter().map(|m| ecfp4(m)).collect();
let mut cliffs: Vec<ActivityCliff> = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
let sim = fps[i].tanimoto(&fps[j]) as f32;
if sim < sim_threshold {
continue;
}
let delta = (activities[i] - activities[j]).abs();
if delta >= cliff_delta {
cliffs.push(ActivityCliff {
mol_a_idx: i,
mol_b_idx: j,
similarity: sim,
activity_delta: delta,
});
}
}
}
cliffs.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
cliffs
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
#[test]
fn test_empty_or_single_molecule() {
let m = mol("c1ccccc1");
assert_eq!(activity_cliffs(&[], &[], 0.5, 1.0).len(), 0);
assert_eq!(activity_cliffs(&[&m], &[5.0], 0.5, 1.0).len(), 0);
}
#[test]
fn test_identical_molecules_no_cliff() {
let m = mol("c1ccccc1");
let cliffs = activity_cliffs(&[&m, &m], &[5.0, 5.0], 0.5, 2.0);
assert_eq!(cliffs.len(), 0, "identical activity → no cliff");
}
#[test]
fn test_dissimilar_molecules_no_cliff_despite_activity_gap() {
let a = mol("c1ccccc1");
let b = mol("c1ccncc1");
let cliffs = activity_cliffs(&[&a, &b], &[5.0, 9.0], 0.9, 2.0);
let _ = cliffs;
}
#[test]
fn test_large_activity_gap_is_cliff_at_low_threshold() {
let benz = mol("c1ccccc1");
let tolu = mol("Cc1ccccc1");
let cliffs = activity_cliffs(&[&benz, &tolu], &[3.0, 7.0], 0.0, 2.0);
assert_eq!(
cliffs.len(),
1,
"benzene/toluene with delta=4.0 and threshold=0 → 1 cliff"
);
assert_eq!(cliffs[0].mol_a_idx, 0);
assert_eq!(cliffs[0].mol_b_idx, 1);
assert!((cliffs[0].activity_delta - 4.0).abs() < 1e-9);
}
#[test]
fn test_no_cliff_when_activity_delta_below_threshold() {
let benz = mol("c1ccccc1");
let tolu = mol("Cc1ccccc1");
let cliffs = activity_cliffs(&[&benz, &tolu], &[5.0, 5.5], 0.0, 2.0);
assert_eq!(cliffs.len(), 0, "small activity delta → no cliff");
}
#[test]
fn test_no_cliff_when_sim_below_threshold() {
let a = mol("c1ccccc1");
let b = mol("CCCC");
let cliffs = activity_cliffs(&[&a, &b], &[3.0, 8.0], 0.8, 2.0);
assert_eq!(
cliffs.len(),
0,
"very dissimilar mols should not form a cliff at high sim threshold"
);
}
#[test]
fn test_sorted_by_similarity_descending() {
let a = mol("c1ccccc1");
let b = mol("Cc1ccccc1");
let c = mol("CCCC");
let cliffs = activity_cliffs(&[&a, &b, &c], &[3.0, 8.0, 7.5], 0.0, 1.0);
for w in cliffs.windows(2) {
assert!(
w[0].similarity >= w[1].similarity,
"cliffs should be sorted by similarity descending"
);
}
}
#[test]
#[should_panic(expected = "mols and activities must have the same length")]
fn test_panic_on_mismatched_lengths() {
let m = mol("c1ccccc1");
activity_cliffs(&[&m], &[5.0, 6.0], 0.5, 1.0);
}
#[test]
fn test_mul_mol_series_low_threshold() {
let smiles = ["c1ccccc1", "Cc1ccccc1", "CCc1ccccc1", "CCCc1ccccc1"];
let mols: Vec<Molecule> = smiles.iter().map(|s| mol(s)).collect();
let refs: Vec<&Molecule> = mols.iter().collect();
let acts = vec![4.0, 6.5, 6.7, 5.0];
let cliffs = activity_cliffs(&refs, &acts, 0.0, 2.0);
assert!(!cliffs.is_empty(), "should find at least one cliff");
for cliff in &cliffs {
assert!(cliff.activity_delta >= 2.0);
}
}
}