use std::collections::HashMap;
use molrs::{AtomId, Atomistic, MatchOptions, SmartsPattern};
use super::deps::OplsDependencyAnalyzer;
use super::meta::OplsTypingMeta;
pub const MAX_CIRCULAR_ITERATIONS: usize = 10;
struct RankedDef {
name: String,
pattern: SmartsPattern,
priority: i64,
specificity: usize,
order: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Rank {
priority: i64,
specificity: usize,
order: usize,
}
impl Rank {
fn beats(&self, other: &Rank) -> bool {
(
self.priority,
self.specificity,
std::cmp::Reverse(self.order),
) > (
other.priority,
other.specificity,
std::cmp::Reverse(other.order),
)
}
}
pub struct LayeredTypingEngine {
analyzer: OplsDependencyAnalyzer,
by_level: Vec<Vec<RankedDef>>,
}
impl LayeredTypingEngine {
pub fn build(meta: &OplsTypingMeta) -> Result<Self, String> {
let analyzer = OplsDependencyAnalyzer::new(meta);
let priorities = meta.priorities();
let mut named: Vec<(&String, &super::meta::OplsTypeRow)> = meta.iter().collect();
named.sort_by(|a, b| a.0.cmp(b.0));
let max_level = analyzer.max_level().unwrap_or(0);
let mut by_level: Vec<Vec<RankedDef>> = (0..=max_level).map(|_| Vec::new()).collect();
for (order, (name, row)) in named.into_iter().enumerate() {
let Some(def) = row.def.as_deref() else {
continue; };
let pattern = compile_def(def).map_err(|e| {
format!("OPLS type {name:?}: failed to parse SMARTS def {def:?}: {e}")
})?;
let specificity = pattern.num_query_atoms();
let level = analyzer.level(name).unwrap_or(0);
if level >= by_level.len() {
by_level.resize_with(level + 1, Vec::new);
}
by_level[level].push(RankedDef {
name: name.clone(),
pattern,
priority: *priorities.get(name).unwrap_or(&0),
specificity,
order,
});
}
Ok(Self { analyzer, by_level })
}
pub fn typify(&self, mol: &Atomistic) -> HashMap<AtomId, String> {
let mut assignments: HashMap<AtomId, String> = HashMap::new();
for level in 0..self.by_level.len() {
let defs = &self.by_level[level];
if defs.is_empty() {
continue;
}
let is_circular = defs.iter().any(|d| self.analyzer.is_circular(&d.name));
assignments = if is_circular {
self.resolve_circular(defs, mol, assignments)
} else {
self.resolve_level(defs, mol, assignments)
};
}
assignments
}
fn resolve_level(
&self,
defs: &[RankedDef],
mol: &Atomistic,
current: HashMap<AtomId, String>,
) -> HashMap<AtomId, String> {
let mut best: HashMap<AtomId, (String, Rank)> = HashMap::new();
for d in defs {
let rank = Rank {
priority: d.priority,
specificity: d.specificity,
order: d.order,
};
for m in d.pattern.find(
mol,
MatchOptions {
labels: Some(¤t),
root: None,
limit: None,
},
) {
let Some(&target) = m.atoms.first() else {
continue;
};
match best.get(&target) {
Some((_, cur)) if !rank.beats(cur) => {}
_ => {
best.insert(target, (d.name.clone(), rank));
}
}
}
}
let mut result = current;
for (atom, (type_name, _)) in best {
result.insert(atom, type_name);
}
result
}
fn resolve_circular(
&self,
defs: &[RankedDef],
mol: &Atomistic,
current: HashMap<AtomId, String>,
) -> HashMap<AtomId, String> {
let mut assignments = current;
for _ in 0..MAX_CIRCULAR_ITERATIONS {
let prev = assignments.clone();
assignments = self.resolve_level(defs, mol, assignments);
if assignments == prev {
break;
}
}
assignments
}
pub fn analyzer(&self) -> &OplsDependencyAnalyzer {
&self.analyzer
}
}
fn compile_def(def: &str) -> Result<SmartsPattern, molrs::MolRsError> {
match SmartsPattern::parse(def) {
Ok(p) => Ok(p),
Err(e) => {
if is_bare_element_symbol(def) {
SmartsPattern::parse(&format!("[{def}]"))
} else {
Err(e)
}
}
}
}
fn is_bare_element_symbol(def: &str) -> bool {
let b = def.as_bytes();
match b.len() {
1 => b[0].is_ascii_uppercase(),
2 => b[0].is_ascii_uppercase() && b[1].is_ascii_lowercase(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ff::typifier::opls::meta::OplsTypeRow;
use molrs::Atom;
fn row(class: &str, def: Option<&str>, overrides: &[&str]) -> OplsTypeRow {
OplsTypeRow {
class: class.to_string(),
def: def.map(str::to_string),
overrides: overrides.iter().map(|s| s.to_string()).collect(),
priority: None,
layer: 0,
}
}
fn meta_with(rows: &[(&str, OplsTypeRow)]) -> OplsTypingMeta {
let mut m = OplsTypingMeta::new();
for (name, r) in rows {
m.insert(*name, r.clone());
}
m
}
fn ethanol() -> (Atomistic, AtomId, AtomId) {
let mut g = Atomistic::new();
let cm = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
let ch = g.add_atom(Atom::xyz("C", 1.5, 0.0, 0.0));
let o = g.add_atom(Atom::xyz("O", 2.5, 0.0, 0.0));
let ho = g.add_atom(Atom::xyz("H", 3.3, 0.0, 0.0));
g.add_bond(cm, ch).unwrap();
g.add_bond(ch, o).unwrap();
g.add_bond(o, ho).unwrap();
for c in [cm, ch] {
let n = if c == cm { 3 } else { 2 };
for k in 0..n {
let h = g.add_atom(Atom::xyz("H", 0.3 * (k as f64 + 1.0), 0.9, 0.0));
g.add_bond(c, h).unwrap();
}
}
(g, o, ho)
}
#[test]
fn level_zero_only_matches_chain1_behaviour() {
let meta = meta_with(&[
("opls_135", row("CT", Some("[C;X4](C)(H)(H)H"), &[])),
("opls_140", row("HC", Some("H[C;X4]"), &[])),
]);
let engine = LayeredTypingEngine::build(&meta).unwrap();
assert_eq!(engine.analyzer().max_level(), Some(0));
let (g, _o, _ho) = ethanol();
let assigned = engine.typify(&g);
let n135 = assigned.values().filter(|t| *t == "opls_135").count();
let n140 = assigned.values().filter(|t| *t == "opls_140").count();
assert_eq!(n135, 1, "one CH3 carbon");
assert!(n140 >= 1, "methyl Hs typed");
}
#[test]
fn dependent_def_resolves_after_its_dependency() {
let meta = meta_with(&[
("opls_154", row("OH", Some("[O;X2](H)([!H])"), &[])),
("opls_155", row("HO", Some("H[O;%opls_154]"), &[])),
]);
let engine = LayeredTypingEngine::build(&meta).unwrap();
assert_eq!(engine.analyzer().level("opls_154"), Some(0));
assert_eq!(engine.analyzer().level("opls_155"), Some(1));
let (g, o, ho) = ethanol();
let assigned = engine.typify(&g);
assert_eq!(
assigned.get(&o).map(String::as_str),
Some("opls_154"),
"alcohol O typed at level 0"
);
assert_eq!(
assigned.get(&ho).map(String::as_str),
Some("opls_155"),
"hydroxyl H typed at level 1 via %opls_154"
);
}
#[test]
fn missing_dependency_leaves_dependent_untyped() {
let meta = meta_with(&[("opls_155", row("HO", Some("H[O;%opls_154]"), &[]))]);
let engine = LayeredTypingEngine::build(&meta).unwrap();
let (g, _o, ho) = ethanol();
let assigned = engine.typify(&g);
assert!(
!assigned.contains_key(&ho),
"no dependency assigned -> dependent stays untyped"
);
}
#[test]
fn circular_level_iterates_to_fixed_point() {
let meta = meta_with(&[
("opls_base", row("CT", Some("[C;X4](C)(H)(H)H"), &[])),
("opls_x", row("X", Some("[C;%opls_base][O;%opls_y]"), &[])),
("opls_y", row("Y", Some("[N;%opls_x]"), &[])),
]);
let engine = LayeredTypingEngine::build(&meta).unwrap();
assert_eq!(engine.analyzer().circular_groups().len(), 1);
let (g, _o, _ho) = ethanol();
let assigned = engine.typify(&g);
assert_eq!(
assigned.values().filter(|t| *t == "opls_base").count(),
1,
"level-0 seed survives the circular level"
);
assert!(
!assigned.values().any(|t| t == "opls_x" || t == "opls_y"),
"cyclic defs cannot fire on ethanol"
);
}
#[test]
fn malformed_def_fails_fast() {
let meta = meta_with(&[("opls_bad", row("X", Some("[C"), &[]))]);
match LayeredTypingEngine::build(&meta) {
Ok(_) => panic!("malformed def should fail fast"),
Err(e) => assert!(e.contains("opls_bad"), "err names the type: {e}"),
}
}
}