use std::sync::OnceLock;
use super::{
Fingerprint, FingerprintError, SsMatcher, hash_combine, rdkit_bond_type_code,
rdkit_fp_bond_between_atoms,
};
use crate::search::query::{
AtomQueryPredicate, BondQueryPredicate, QueryNode, build_query_match_context,
is_complex_atom_query,
};
use crate::search::substruct::{
SubstructMatchParams, try_get_substruct_matches_with_params_and_context,
};
use crate::{Bond, BondOrder, Molecule};
pub const PATTERN_FINGERPRINT_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PatternFingerprintParams {
pub n_bits: usize,
pub tautomeric: bool,
}
impl Default for PatternFingerprintParams {
fn default() -> Self {
Self {
n_bits: 2048,
tautomeric: false,
}
}
}
pub fn pattern_fingerprint(
molecule: &Molecule,
params: &PatternFingerprintParams,
) -> Result<Fingerprint, FingerprintError> {
if params.n_bits == 0 {
return Err(FingerprintError::EmptyFingerprint);
}
let mut fingerprint = Fingerprint::zeroed(params.n_bits);
update_pattern_fingerprint(
molecule,
&mut fingerprint,
params.n_bits,
None,
None,
params.tautomeric,
)?;
Ok(fingerprint)
}
pub(super) const PATTERN_FINGERPRINT_SMARTS: [&str; 13] = [
"[*]~[*]",
"[*]~[*]~[*]",
"[R]~1~[R]~[R]~1",
"[*]~[*](~[*])~[*]",
"[R]~1[R]~[R]~[R]~1",
"[*]~[*]~[*](~[*])~[*]",
"[R]~1~[R]~[R]~[R]~[R]~1",
"[R]~1~[R]~[R]~[R]~[R]~[R]~1",
"[R](@[R])(@[R])~[R]~[R](@[R])(@[R])",
"[R](@[R])(@[R])~[R]@[R]~[R](@[R])(@[R])",
"[*]~[R](@[R])@[R](@[R])~[*]",
"[*]~[R](@[R])@[R]@[R](@[R])~[*]",
"[*]",
];
pub(super) fn compiled_pattern_fingerprint_queries()
-> Result<&'static [SsMatcher], FingerprintError> {
static CACHE: OnceLock<Result<Vec<SsMatcher>, FingerprintError>> = OnceLock::new();
match CACHE.get_or_init(|| {
PATTERN_FINGERPRINT_SMARTS
.iter()
.map(|pattern| SsMatcher::try_new(pattern))
.collect()
}) {
Ok(matchers) => Ok(matchers.as_slice()),
Err(error) => Err(error.clone()),
}
}
#[inline]
pub(super) fn is_pattern_complex_query(bond: &Bond) -> bool {
match bond.query() {
None => false,
Some(QueryNode::Not(_)) => true,
Some(QueryNode::Predicate(BondQueryPredicate::Order(_))) => false,
Some(_) => true,
}
}
#[inline]
pub(super) fn is_tautomer_bond_query(bond: &Bond) -> bool {
let query = match bond.query() {
Some(QueryNode::Not(child)) => child.as_ref(),
Some(query) => query,
None => return false,
};
matches!(
query,
QueryNode::Predicate(BondQueryPredicate::OrderIn(orders))
if orders.as_slice() == [BondOrder::Single, BondOrder::Aromatic]
|| orders.as_slice()
== [BondOrder::Single, BondOrder::Double, BondOrder::Aromatic]
)
}
fn update_pattern_fingerprint(
molecule: &Molecule,
fingerprint: &mut Fingerprint,
fingerprint_size: usize,
atom_counts: Option<&mut [u32]>,
set_only_bits: Option<&Fingerprint>,
tautomeric_fingerprint: bool,
) -> Result<(), FingerprintError> {
update_pattern_fingerprint_impl(
molecule,
fingerprint,
fingerprint_size,
atom_counts,
set_only_bits,
tautomeric_fingerprint,
|_| {},
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PatternTraceEvent {
PatternMatches {
pattern_index: u32,
count: usize,
},
CountBit {
pattern_index: u32,
occurrence: usize,
seed: u32,
bit: usize,
},
AtomHash {
pattern_index: u32,
atomic_number: u32,
seed: u32,
},
BondHash {
pattern_index: u32,
bond_code: u32,
seed: u32,
},
QueryAtomSuppressed {
pattern_index: u32,
atom_index: usize,
},
QueryBondSuppressed {
pattern_index: u32,
bond_index: usize,
},
TautomerQueryBond {
pattern_index: u32,
bond_index: usize,
},
StructureBit {
pattern_index: u32,
seed: u32,
bit: usize,
},
TautomerBit {
pattern_index: u32,
seed: u32,
bit: usize,
},
}
#[allow(clippy::too_many_arguments)]
fn update_pattern_fingerprint_impl(
molecule: &Molecule,
fingerprint: &mut Fingerprint,
fingerprint_size: usize,
atom_counts: Option<&mut [u32]>,
set_only_bits: Option<&Fingerprint>,
tautomeric_fingerprint: bool,
mut trace: impl FnMut(PatternTraceEvent),
) -> Result<(), FingerprintError> {
if fingerprint_size == 0 {
return Err(FingerprintError::EmptyFingerprint);
}
if fingerprint.n_bits() != fingerprint_size {
return Err(FingerprintError::BitLengthMismatch {
left: fingerprint.n_bits(),
right: fingerprint_size,
});
}
if atom_counts
.as_ref()
.is_some_and(|counts| counts.len() < molecule.num_atoms())
{
return Err(FingerprintError::InvalidArguments {
reason: "Pattern atom_counts length is smaller than molecule atom count",
});
}
if set_only_bits.is_some_and(|bits| bits.n_bits() != fingerprint_size) {
return Err(FingerprintError::InvalidArguments {
reason: "Pattern set_only_bits length differs from fingerprint size",
});
}
let patterns = compiled_pattern_fingerprint_queries()?;
let is_query_atom: Vec<bool> = molecule
.atoms()
.iter()
.map(|atom| {
atom.query().is_some()
&& (matches!(
atom.query(),
Some(QueryNode::Predicate(AtomQueryPredicate::Any))
) || is_complex_atom_query(atom))
})
.collect();
let mut is_query_bond = vec![false; molecule.num_bonds()];
let mut is_tautomer_bond = vec![false; molecule.num_bonds()];
for bond in molecule.bonds() {
if is_pattern_complex_query(bond) {
is_query_bond[bond.id().index()] = true;
if tautomeric_fingerprint && is_tautomer_bond_query(bond) {
is_tautomer_bond[bond.id().index()] = true;
}
}
}
let mut params = SubstructMatchParams::default();
params.uniquify = false;
params.max_matches = 100_000_000;
let query_context = build_query_match_context(molecule);
for (pattern_offset, matcher) in patterns.iter().enumerate() {
let pattern_index = pattern_offset as u32 + 1;
let pattern = matcher.getMatcher();
let matches = try_get_substruct_matches_with_params_and_context(
molecule,
pattern,
¶ms,
&query_context,
)
.map_err(|error| FingerprintError::Pattern {
reason: error.to_string(),
})?;
trace(PatternTraceEvent::PatternMatches {
pattern_index,
count: matches.len(),
});
let mut match_index = pattern_index
.wrapping_add(pattern.num_atoms() as u32)
.wrapping_add(pattern.num_bonds() as u32);
for (occurrence, matched) in matches.into_iter().enumerate() {
hash_combine(&mut match_index, 0xBEEF);
let count_bit = match_index as usize % fingerprint_size;
fingerprint.set_bit(count_bit);
trace(PatternTraceEvent::CountBit {
pattern_index,
occurrence,
seed: match_index,
bit: count_bit,
});
let mut is_query = false;
let mut bit_id = pattern_index;
let mut atom_map = vec![0usize; matched.atom_mapping.len()];
for (query_atom_index, &molecule_atom_index) in matched.atom_mapping.iter().enumerate()
{
if is_query_atom[molecule_atom_index] {
is_query = true;
trace(PatternTraceEvent::QueryAtomSuppressed {
pattern_index,
atom_index: molecule_atom_index,
});
break;
}
let atomic_number =
u32::from(molecule.atoms()[molecule_atom_index].atomic_number());
hash_combine(&mut bit_id, atomic_number);
trace(PatternTraceEvent::AtomHash {
pattern_index,
atomic_number,
seed: bit_id,
});
atom_map[query_atom_index] = molecule_atom_index;
}
if is_query {
continue;
}
let mut tautomer_bit_id = bit_id;
let mut tautomer_query = false;
for pattern_bond in pattern.bonds() {
let molecule_bond_index = rdkit_fp_bond_between_atoms(
molecule,
atom_map[pattern_bond.begin().index()],
atom_map[pattern_bond.end().index()],
)
.expect("substructure atom mapping must preserve query bonds");
let molecule_bond = &molecule.bonds()[molecule_bond_index];
if is_query_bond[molecule_bond_index] {
is_query = true;
if is_tautomer_bond[molecule_bond_index] {
is_query = false;
tautomer_query = true;
trace(PatternTraceEvent::TautomerQueryBond {
pattern_index,
bond_index: molecule_bond_index,
});
}
if is_query {
trace(PatternTraceEvent::QueryBondSuppressed {
pattern_index,
bond_index: molecule_bond_index,
});
break;
}
}
if tautomeric_fingerprint
&& (is_tautomer_bond[molecule_bond_index]
|| molecule_bond.is_aromatic()
|| matches!(
molecule_bond.order(),
BondOrder::Single | BondOrder::Double | BondOrder::Aromatic
))
{
hash_combine(&mut tautomer_bit_id, u32::MAX);
}
if !tautomer_query {
let bond_code = if molecule_bond.is_aromatic() {
rdkit_bond_type_code(BondOrder::Aromatic)
} else {
rdkit_bond_type_code(molecule_bond.order())
};
hash_combine(&mut bit_id, bond_code);
trace(PatternTraceEvent::BondHash {
pattern_index,
bond_code,
seed: bit_id,
});
}
}
if !is_query {
if !tautomer_query {
let bit = bit_id as usize % fingerprint_size;
fingerprint.set_bit(bit);
trace(PatternTraceEvent::StructureBit {
pattern_index,
seed: bit_id,
bit,
});
}
if tautomeric_fingerprint {
let bit = tautomer_bit_id as usize % fingerprint_size;
fingerprint.set_bit(bit);
trace(PatternTraceEvent::TautomerBit {
pattern_index,
seed: tautomer_bit_id,
bit,
});
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Barrier};
use super::*;
use crate::{AtomId, BondId, BondSpec};
fn test_bond(query: Option<QueryNode<BondQueryPredicate>>) -> Bond {
let spec = BondSpec::new(AtomId::new(0), AtomId::new(1), BondOrder::Unspecified);
let spec = match query {
Some(query) => spec.with_query(query),
None => spec,
};
Bond::from_spec(BondId::new(0), spec)
}
#[test]
fn pattern_compiled_queries_preserve_source_order_and_share_one_cache() {
let first = compiled_pattern_fingerprint_queries().expect("compile Pattern SMARTS");
let second = compiled_pattern_fingerprint_queries().expect("reuse Pattern SMARTS");
assert_eq!(first.len(), PATTERN_FINGERPRINT_SMARTS.len());
assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
for (matcher, expected) in first.iter().zip(PATTERN_FINGERPRINT_SMARTS) {
let reparsed = crate::search::smarts_parse::mol_from_smarts(
expected,
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("reparse source Pattern SMARTS");
assert_eq!(matcher.getMatcher().num_atoms(), reparsed.num_atoms());
assert_eq!(matcher.getMatcher().num_bonds(), reparsed.num_bonds());
}
}
#[test]
fn pattern_compiled_queries_are_shared_during_concurrent_access() {
const THREADS: usize = 16;
let barrier = Arc::new(Barrier::new(THREADS));
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
let matchers =
compiled_pattern_fingerprint_queries().expect("concurrent Pattern cache");
(matchers.as_ptr() as usize, matchers.len())
})
})
.collect();
let results: Vec<_> = handles
.into_iter()
.map(|handle| handle.join().expect("Pattern cache thread"))
.collect();
assert!(results.iter().all(|result| *result == results[0]));
assert_eq!(results[0].1, PATTERN_FINGERPRINT_SMARTS.len());
}
#[test]
fn pattern_query_classifiers_reproduce_description_and_negation_semantics() {
let order = |order| QueryNode::predicate(BondQueryPredicate::Order(order));
let order_in = |orders| QueryNode::predicate(BondQueryPredicate::OrderIn(orders));
assert!(!is_pattern_complex_query(&test_bond(None)));
assert!(!is_pattern_complex_query(&test_bond(Some(order(
BondOrder::Single,
)))));
assert!(is_pattern_complex_query(&test_bond(Some(QueryNode::not(
order(BondOrder::Single),
)))));
assert!(is_pattern_complex_query(&test_bond(Some(order_in(vec![
BondOrder::Single,
BondOrder::Aromatic,
])))));
assert!(is_pattern_complex_query(&test_bond(Some(QueryNode::or(
vec![order(BondOrder::Single), order(BondOrder::Aromatic)],
)))));
for query in [
order_in(vec![BondOrder::Single, BondOrder::Aromatic]),
order_in(vec![
BondOrder::Single,
BondOrder::Double,
BondOrder::Aromatic,
]),
QueryNode::not(order_in(vec![BondOrder::Single, BondOrder::Aromatic])),
] {
assert!(is_tautomer_bond_query(&test_bond(Some(query))));
}
for query in [
order(BondOrder::Single),
order_in(vec![BondOrder::Aromatic, BondOrder::Single]),
order_in(vec![BondOrder::Double, BondOrder::Aromatic]),
order_in(vec![BondOrder::Single, BondOrder::Double]),
QueryNode::or(vec![order(BondOrder::Single), order(BondOrder::Aromatic)]),
] {
assert!(!is_tautomer_bond_query(&test_bond(Some(query))));
}
assert!(!is_tautomer_bond_query(&test_bond(None)));
}
fn traced_pattern_fingerprint(
molecule: &Molecule,
fingerprint_size: usize,
tautomeric: bool,
) -> (Fingerprint, Vec<PatternTraceEvent>) {
let mut fingerprint = Fingerprint::zeroed(fingerprint_size);
let mut events = Vec::new();
update_pattern_fingerprint_impl(
molecule,
&mut fingerprint,
fingerprint_size,
None,
None,
tautomeric,
|event| events.push(event),
)
.expect("Pattern fingerprint");
(fingerprint, events)
}
#[test]
fn pattern_core_matches_exact_ethane_hash_evolution_and_tautomer_bits() {
let molecule = Molecule::from_smiles("CC").expect("ethane");
let (ordinary, ordinary_events) = traced_pattern_fingerprint(&molecule, 2048, false);
assert_eq!(ordinary.on_bits(), vec![429, 778, 1022, 1061, 1236, 1295]);
assert!(ordinary_events.contains(&PatternTraceEvent::CountBit {
pattern_index: 1,
occurrence: 0,
seed: 2_654_484_909,
bit: 429,
}));
assert!(ordinary_events.contains(&PatternTraceEvent::CountBit {
pattern_index: 1,
occurrence: 1,
seed: 3_454_831_614,
bit: 1022,
}));
assert!(ordinary_events.contains(&PatternTraceEvent::AtomHash {
pattern_index: 1,
atomic_number: 6,
seed: 2_654_435_838,
}));
assert!(ordinary_events.contains(&PatternTraceEvent::BondHash {
pattern_index: 1,
bond_code: 1,
seed: 4_217_150_218,
}));
assert!(ordinary_events.contains(&PatternTraceEvent::StructureBit {
pattern_index: 1,
seed: 4_217_150_218,
bit: 778,
}));
let (tautomeric, tautomeric_events) = traced_pattern_fingerprint(&molecule, 2048, true);
assert_eq!(
tautomeric.on_bits(),
vec![429, 776, 778, 1022, 1061, 1236, 1295]
);
assert!(tautomeric_events.contains(&PatternTraceEvent::TautomerBit {
pattern_index: 1,
seed: 4_217_150_216,
bit: 776,
}));
}
#[test]
fn pattern_core_exercises_every_source_pattern_with_non_unique_matches() {
let fixtures = [
"CC",
"CCC",
"C1CC1",
"CC(C)C",
"C1CCC1",
"CC(C)CC",
"C1CCCC1",
"C1CCCCC1",
"C1CC2CCC1C2",
"C12C3C4C1C5C2C3C45",
"c1ccc2ccccc2c1",
"C1C2CC3CC1CC(C2)C3",
"[Na+]",
];
let mut maximum_counts = [0usize; 13];
for smiles in fixtures {
let molecule = Molecule::from_smiles(smiles).expect("Pattern fixture");
let (_, events) = traced_pattern_fingerprint(&molecule, 2048, false);
for event in events {
if let PatternTraceEvent::PatternMatches {
pattern_index,
count,
} = event
{
maximum_counts[pattern_index as usize - 1] =
maximum_counts[pattern_index as usize - 1].max(count);
}
}
}
assert!(
maximum_counts.iter().all(|&count| count > 0),
"every source pattern must be exercised: {maximum_counts:?}"
);
assert!(
maximum_counts.iter().all(|&count| count != 1),
"non-unique matching must preserve symmetry multiplicity: {maximum_counts:?}"
);
}
#[test]
fn pattern_core_normalizes_aromatic_bonds_and_keeps_single_atom_pattern() {
let benzene = Molecule::from_smiles("c1ccccc1").expect("benzene");
let (_, events) = traced_pattern_fingerprint(&benzene, 2048, false);
assert!(
events
.iter()
.any(|event| matches!(event, PatternTraceEvent::BondHash { bond_code: 12, .. }))
);
let sodium = Molecule::from_smiles("[Na+]").expect("sodium");
let (fingerprint, events) = traced_pattern_fingerprint(&sodium, 2048, false);
assert!(events.contains(&PatternTraceEvent::PatternMatches {
pattern_index: 13,
count: 1,
}));
assert!(events.iter().any(|event| matches!(
event,
PatternTraceEvent::StructureBit {
pattern_index: 13,
..
}
)));
assert!(!fingerprint.on_bits().is_empty());
}
#[test]
fn pattern_core_suppresses_query_atoms_and_non_tautomer_query_bonds() {
let query_atom = crate::search::smarts_parse::mol_from_smarts(
"[*]",
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("query atom");
let (_, atom_events) = traced_pattern_fingerprint(&query_atom, 2048, false);
assert!(
atom_events
.iter()
.any(|event| matches!(event, PatternTraceEvent::QueryAtomSuppressed { .. }))
);
assert!(!atom_events.iter().any(|event| matches!(
event,
PatternTraceEvent::StructureBit {
pattern_index: 13,
..
}
)));
let query_bond = crate::search::smarts_parse::mol_from_smarts(
"C~C",
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("query bond");
let (_, bond_events) = traced_pattern_fingerprint(&query_bond, 2048, false);
assert!(bond_events.iter().any(|event| matches!(
event,
PatternTraceEvent::QueryBondSuppressed {
pattern_index: 1,
..
}
)));
assert_eq!(
traced_pattern_fingerprint(&query_bond, 257, false)
.0
.on_bits(),
vec![14, 44, 132, 136, 146]
);
let any_query = crate::search::smarts_parse::mol_from_smarts(
"C~N",
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("any query bond");
assert_eq!(
traced_pattern_fingerprint(&any_query, 257, false)
.0
.on_bits(),
vec![14, 43, 44, 132, 136, 146]
);
let order_query = crate::search::smarts_parse::mol_from_smarts(
"C-,=N",
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("single-or-double query bond");
assert_eq!(
traced_pattern_fingerprint(&order_query, 257, false)
.0
.on_bits(),
vec![14, 43, 44, 132, 136, 146]
);
}
#[test]
fn pattern_core_tautomer_query_uses_u32_max_hash_and_suppresses_structure_bit() {
let query = crate::search::smarts_parse::mol_from_smarts(
"CC",
&crate::search::smarts_parse::SmartsParseParams::default(),
)
.expect("single-or-aromatic query bond");
let (_, events) = traced_pattern_fingerprint(&query, 2048, true);
assert!(events.iter().any(|event| matches!(
event,
PatternTraceEvent::TautomerQueryBond {
pattern_index: 1,
..
}
)));
assert!(!events.iter().any(|event| matches!(
event,
PatternTraceEvent::StructureBit {
pattern_index: 1,
..
}
)));
assert!(events.iter().any(|event| matches!(
event,
PatternTraceEvent::TautomerBit {
pattern_index: 1,
..
}
)));
}
#[test]
fn pattern_core_width_collision_and_inert_arguments_match_source() {
let molecule = Molecule::from_smiles("CCC").expect("propane");
let (width_one, _) = traced_pattern_fingerprint(&molecule, 1, true);
assert_eq!(width_one.on_bits(), vec![0]);
let mut baseline = Fingerprint::zeroed(127);
update_pattern_fingerprint(&molecule, &mut baseline, 127, None, None, true)
.expect("baseline");
let mut atom_counts = vec![17; molecule.num_atoms()];
let set_only_bits = Fingerprint::from_on_bits(127, [0, 5, 126]);
let mut with_inert_arguments = Fingerprint::zeroed(127);
update_pattern_fingerprint(
&molecule,
&mut with_inert_arguments,
127,
Some(&mut atom_counts),
Some(&set_only_bits),
true,
)
.expect("inert arguments");
assert_eq!(with_inert_arguments, baseline);
assert_eq!(atom_counts, vec![17; molecule.num_atoms()]);
let mut invalid = Fingerprint::zeroed(127);
assert!(matches!(
update_pattern_fingerprint(
&molecule,
&mut invalid,
127,
Some(&mut [0; 2]),
None,
false,
),
Err(FingerprintError::InvalidArguments { .. })
));
assert!(matches!(
update_pattern_fingerprint(
&molecule,
&mut invalid,
127,
None,
Some(&Fingerprint::zeroed(128)),
false,
),
Err(FingerprintError::InvalidArguments { .. })
));
let mut empty = Fingerprint::zeroed(0);
assert_eq!(
update_pattern_fingerprint(&molecule, &mut empty, 0, None, None, false),
Err(FingerprintError::EmptyFingerprint)
);
}
}