use std::collections::{HashMap, HashSet, VecDeque};
use chematic::core::{AtomIdx, BondOrder};
use chematic::rxn::{ReactionMatch, apply_reaction_match, find_reaction_matches};
use rustc_hash::FxHashMap;
use serde::Serialize;
use crate::chem_env::{Molecule, RetroRule, mol_from_smiles, split_fragments, to_canonical};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SpectatorBondLossCase {
MatchedPairUndeclared,
CrossProductTerritory,
}
fn bond_order_label(order: BondOrder) -> &'static str {
match order {
BondOrder::Single => "single",
BondOrder::Double => "double",
BondOrder::Triple => "triple",
BondOrder::Quadruple => "quadruple",
BondOrder::Aromatic => "aromatic",
BondOrder::Up => "up",
BondOrder::Down => "down",
BondOrder::Zero => "zero",
BondOrder::Dative => "dative",
BondOrder::QueryAny => "query_any",
BondOrder::QuerySingleOrDouble => "query_single_or_double",
BondOrder::QuerySingleOrAromatic => "query_single_or_aromatic",
BondOrder::QueryDoubleOrAromatic => "query_double_or_aromatic",
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LostBond {
pub source_atom_a: u32,
pub source_atom_b: u32,
pub bond_order: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpectatorBondLossFinding {
pub target_smiles: String,
pub template_id: String,
pub rule_name: String,
pub case: SpectatorBondLossCase,
pub lost_bonds: Vec<LostBond>,
pub evidence: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SpectatorBondPolicy {
#[default]
Off,
DiagnosticsOnly,
Gated,
}
#[derive(Debug, Clone, Serialize)]
pub struct GatedCandidateRecord {
pub rule_name: String,
pub template_id: String,
pub precursor_smiles: Vec<String>,
pub findings: Vec<SpectatorBondLossFinding>,
}
fn declared_map_pairs(pattern: &str) -> Option<HashSet<(u16, u16)>> {
let mol = mol_from_smiles(pattern.trim()).ok()?;
let mut pairs = HashSet::new();
for (_, bond) in mol.bonds() {
let a = mol.atom(bond.atom1).atom_map;
let b = mol.atom(bond.atom2).atom_map;
if let (Some(a), Some(b)) = (a, b) {
pairs.insert(if a < b { (a, b) } else { (b, a) });
}
}
Some(pairs)
}
fn case_a_lost_bonds_for_match(
target: &Molecule,
declared: &HashSet<(u16, u16)>,
positions: &FxHashMap<u16, (usize, AtomIdx)>,
) -> Vec<LostBond> {
let map_numbers: Vec<u16> = positions.keys().copied().collect();
let mut lost_bonds = Vec::new();
for i in 0..map_numbers.len() {
for j in (i + 1)..map_numbers.len() {
let (mi, mj) = (map_numbers[i], map_numbers[j]);
let key = if mi < mj { (mi, mj) } else { (mj, mi) };
if declared.contains(&key) {
continue;
}
let (_, ai) = positions[&mi];
let (_, aj) = positions[&mj];
let Some((_, bond)) = target.bond_between(ai, aj) else {
continue;
};
lost_bonds.push(LostBond {
source_atom_a: ai.0,
source_atom_b: aj.0,
bond_order: bond_order_label(bond.order),
});
}
}
lost_bonds
}
pub fn detect_case_a(target: &Molecule, rule: &RetroRule) -> Vec<SpectatorBondLossFinding> {
if rule.smirks.is_empty() {
return Vec::new();
}
let Some((lhs, rhs)) = rule.smirks.split_once(">>") else {
return Vec::new();
};
let mut declared = declared_map_pairs(lhs).unwrap_or_default();
for fragment in rhs.split('.') {
declared.extend(declared_map_pairs(fragment).unwrap_or_default());
}
let Ok(matches) = find_reaction_matches(&rule.smirks, &[target]) else {
return Vec::new();
};
let mut findings = Vec::new();
let mut seen_bonds: HashSet<(u32, u32)> = HashSet::new();
for m in &matches {
let Ok(positions) = m.atom_map_positions(&rule.smirks) else {
continue;
};
let mut lost_bonds = Vec::new();
for bond in case_a_lost_bonds_for_match(target, &declared, &positions) {
let bond_key = (
bond.source_atom_a.min(bond.source_atom_b),
bond.source_atom_a.max(bond.source_atom_b),
);
if seen_bonds.insert(bond_key) {
lost_bonds.push(bond);
}
}
if !lost_bonds.is_empty() {
findings.push(SpectatorBondLossFinding {
target_smiles: to_canonical(target),
template_id: rule.template_id.clone(),
rule_name: rule.name.clone(),
case: SpectatorBondLossCase::MatchedPairUndeclared,
evidence: format!(
"{} bond(s) between matched atoms exist in the target but are declared \
broken by neither {}'s LHS nor any RHS fragment",
lost_bonds.len(),
rule.name
),
lost_bonds,
});
}
}
findings
}
pub fn detect_case_b(target: &Molecule, rule: &RetroRule) -> Vec<SpectatorBondLossFinding> {
if rule.smirks.is_empty() {
return Vec::new();
}
let Some((_, rhs)) = rule.smirks.split_once(">>") else {
return Vec::new();
};
let fragments: Vec<&str> = rhs.split('.').map(str::trim).collect();
if fragments.len() < 2 {
return Vec::new(); }
let owner = rhs_fragment_owner_map(&fragments);
let Ok(matches) = find_reaction_matches(&rule.smirks, &[target]) else {
return Vec::new();
};
let mut findings = Vec::new();
for m in &matches {
let Ok(positions) = m.atom_map_positions(&rule.smirks) else {
continue;
};
for (mi, mj, lost_bonds) in case_b_lost_bond_chains_for_match(target, &owner, &positions) {
findings.push(SpectatorBondLossFinding {
target_smiles: to_canonical(target),
template_id: rule.template_id.clone(),
rule_name: rule.name.clone(),
case: SpectatorBondLossCase::CrossProductTerritory,
evidence: format!(
"matched atoms {mi} and {mj} (declared by different RHS product \
fragments in {}) are connected in the target only through unmatched \
atoms -- chematic assembles each product fragment as a separate \
molecule, so this chain can never survive intact in either output",
rule.name
),
lost_bonds,
});
}
}
findings
}
fn rhs_fragment_owner_map(fragments: &[&str]) -> HashMap<u16, usize> {
let mut owner = HashMap::new();
for (k, frag) in fragments.iter().enumerate() {
let Ok(mol) = mol_from_smiles(frag) else {
continue;
};
for (_, atom) in mol.atoms() {
if let Some(m) = atom.atom_map {
owner.insert(m, k);
}
}
}
owner
}
fn case_b_lost_bond_chains_for_match(
target: &Molecule,
owner: &HashMap<u16, usize>,
positions: &FxHashMap<u16, (usize, AtomIdx)>,
) -> Vec<(u16, u16, Vec<LostBond>)> {
let all_matched: HashSet<AtomIdx> = positions.values().map(|&(_, a)| a).collect();
let map_numbers: Vec<u16> = positions.keys().copied().collect();
let mut checked_pairs: HashSet<(u16, u16)> = HashSet::new();
let mut chains = Vec::new();
for i in 0..map_numbers.len() {
for j in (i + 1)..map_numbers.len() {
let (mi, mj) = (map_numbers[i], map_numbers[j]);
let (Some(&owner_i), Some(&owner_j)) = (owner.get(&mi), owner.get(&mj)) else {
continue; };
if owner_i == owner_j {
continue; }
let key = if mi < mj { (mi, mj) } else { (mj, mi) };
if !checked_pairs.insert(key) {
continue;
}
let (_, a) = positions[&mi];
let (_, b) = positions[&mj];
if target.bond_between(a, b).is_some() {
continue; }
let Some(path) = unmatched_only_path(target, a, b, &all_matched) else {
continue;
};
let lost_bonds: Vec<LostBond> = path
.windows(2)
.filter_map(|w| {
target.bond_between(w[0], w[1]).map(|(_, bond)| LostBond {
source_atom_a: w[0].0,
source_atom_b: w[1].0,
bond_order: bond_order_label(bond.order),
})
})
.collect();
if !lost_bonds.is_empty() {
chains.push((mi, mj, lost_bonds));
}
}
}
chains
}
fn unmatched_only_path(
target: &Molecule,
start: AtomIdx,
end: AtomIdx,
blocked: &HashSet<AtomIdx>,
) -> Option<Vec<AtomIdx>> {
let mut queue: VecDeque<AtomIdx> = VecDeque::from([start]);
let mut came_from: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut visited: HashSet<AtomIdx> = HashSet::from([start]);
while let Some(cur) = queue.pop_front() {
if cur == end {
let mut path = vec![end];
let mut node = end;
while let Some(&prev) = came_from.get(&node) {
path.push(prev);
node = prev;
}
path.reverse();
return Some(path);
}
for (nb, _) in target.neighbors(cur) {
if visited.contains(&nb) || (nb != end && blocked.contains(&nb)) {
continue;
}
visited.insert(nb);
came_from.insert(nb, cur);
queue.push_back(nb);
}
}
None
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum SpectatorBondGateVerdict {
Accepted,
Rejected {
findings: Vec<SpectatorBondLossFinding>,
},
NotEvaluable {
reason: &'static str,
},
}
pub fn gate_candidates(
target: &Molecule,
rule: &RetroRule,
candidate_signatures: &[Vec<String>],
) -> Vec<SpectatorBondGateVerdict> {
let accept_all = || vec![SpectatorBondGateVerdict::Accepted; candidate_signatures.len()];
let not_evaluable_all = |reason: &'static str| {
vec![SpectatorBondGateVerdict::NotEvaluable { reason }; candidate_signatures.len()]
};
if rule.smirks.is_empty() {
return accept_all(); }
if rule.smirks.contains('#') {
return not_evaluable_all("hash_atom_wildcard_rule");
}
let Some((lhs, rhs)) = rule.smirks.split_once(">>") else {
return accept_all();
};
let Some(mut declared) = declared_map_pairs(lhs) else {
return not_evaluable_all("unparseable_declared_bonds");
};
for fragment in rhs.split('.') {
let Some(frag_pairs) = declared_map_pairs(fragment) else {
return not_evaluable_all("unparseable_declared_bonds");
};
declared.extend(frag_pairs);
}
let fragments: Vec<&str> = rhs.split('.').map(str::trim).collect();
let owner = rhs_fragment_owner_map(&fragments);
let Ok(matches) = find_reaction_matches(&rule.smirks, &[target]) else {
return accept_all();
};
let mut by_signature: HashMap<Vec<String>, Vec<Vec<SpectatorBondLossFinding>>> = HashMap::new();
for m in &matches {
let Some(signature) = replay_match_signature(&rule.smirks, target, m) else {
continue; };
let Ok(positions) = m.atom_map_positions(&rule.smirks) else {
continue;
};
let mut findings = Vec::new();
let case_a = case_a_lost_bonds_for_match(target, &declared, &positions);
if !case_a.is_empty() {
findings.push(build_finding(
target,
rule,
SpectatorBondLossCase::MatchedPairUndeclared,
case_a,
));
}
for (_, _, lost_bonds) in case_b_lost_bond_chains_for_match(target, &owner, &positions) {
findings.push(build_finding(
target,
rule,
SpectatorBondLossCase::CrossProductTerritory,
lost_bonds,
));
}
by_signature.entry(signature).or_default().push(findings);
}
candidate_signatures
.iter()
.map(|sig| match by_signature.get(sig) {
None => SpectatorBondGateVerdict::NotEvaluable {
reason: "match_correlation_failed",
},
Some(per_match) => {
let defective: Vec<&Vec<SpectatorBondLossFinding>> =
per_match.iter().filter(|f| !f.is_empty()).collect();
let clean_count = per_match.len() - defective.len();
if !defective.is_empty() && clean_count > 0 {
SpectatorBondGateVerdict::NotEvaluable {
reason: "ambiguous_signature",
}
} else if let Some(first_defective) = defective.first() {
SpectatorBondGateVerdict::Rejected {
findings: (*first_defective).clone(),
}
} else {
SpectatorBondGateVerdict::Accepted
}
}
})
.collect()
}
fn replay_match_signature(
smirks: &str,
target: &Molecule,
m: &ReactionMatch,
) -> Option<Vec<String>> {
let products = apply_reaction_match(smirks, &[target], m, true).ok()??;
let mut signature: Vec<String> = products
.iter()
.flat_map(split_fragments)
.map(|p| p.smiles)
.collect();
signature.sort_unstable();
Some(signature)
}
fn build_finding(
target: &Molecule,
rule: &RetroRule,
case: SpectatorBondLossCase,
lost_bonds: Vec<LostBond>,
) -> SpectatorBondLossFinding {
let evidence = match case {
SpectatorBondLossCase::MatchedPairUndeclared => format!(
"{} bond(s) between matched atoms exist in the target but are declared broken by \
neither {}'s LHS nor any RHS fragment",
lost_bonds.len(),
rule.name
),
SpectatorBondLossCase::CrossProductTerritory => format!(
"matched atoms (declared by different RHS product fragments in {}) are connected \
in the target only through unmatched atoms -- chematic assembles each product \
fragment as a separate molecule, so this chain can never survive intact in either \
output",
rule.name
),
};
SpectatorBondLossFinding {
target_smiles: to_canonical(target),
template_id: rule.template_id.clone(),
rule_name: rule.name.clone(),
case,
evidence,
lost_bonds,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rr(name: &str, smirks: &str) -> RetroRule {
RetroRule {
name: name.into(),
template_id: format!("rule:{name}"),
smirks: smirks.into(),
..Default::default()
}
}
#[test]
fn declared_map_pairs_reads_simple_chain() {
let pairs = declared_map_pairs("[C:1]-[O:2]-[C:3]").unwrap();
assert_eq!(pairs, HashSet::from([(1, 2), (2, 3)]));
}
#[test]
fn declared_map_pairs_ignores_unmapped_atoms() {
let pairs = declared_map_pairs("[C:1]OC").unwrap();
assert!(pairs.iter().all(|&(a, b)| a == 1 || b == 1));
}
#[test]
fn declared_map_pairs_none_for_unparseable_pattern() {
assert_eq!(declared_map_pairs("not smiles at all !!"), None);
}
#[test]
fn detects_extracted_4255_difluorophthalide_ring_bond() {
let target = mol_from_smiles("C2c1cc(c(F)cc1C(O2)=O)F").unwrap();
let rule = rr(
"extracted_4255",
"[C:2]-[O:3]-[CH2:1]-[c:5](:[c:4]):[c:6]>>O=[CH2:1].[C:2]-[OH:3].[c:4]:[cH:5]:[c:6]",
);
let findings = detect_case_a(&target, &rule);
assert!(
!findings.is_empty(),
"must detect the undeclared bond between the carbonyl carbon (map 2) and its \
aromatic ring neighbor (map 6)"
);
}
#[test]
fn detects_extracted_824_oxazolidinone_ring_bond() {
let target = mol_from_smiles("O=C2NCC(O2)Cc1ccccc1").unwrap();
let rule = rr(
"extracted_824",
"[C:5]-[O:6]-[C:3](=[O:4])-[NH:2]-[C:1]>>[C:1]-[N:2]=[C:3]=[O:4].[C:5]-[OH:6]",
);
let findings = detect_case_a(&target, &rule);
assert!(
!findings.is_empty(),
"must detect the undeclared C1-C5 ring-closing bond"
);
assert_eq!(
findings[0].case,
SpectatorBondLossCase::MatchedPairUndeclared
);
assert_eq!(
findings.iter().map(|f| f.lost_bonds.len()).sum::<usize>(),
1,
"exactly one real bond (C1-C5) is undeclared here"
);
assert_eq!(
findings[0].target_smiles,
to_canonical(&target),
"target_smiles must identify the exact target this finding was detected against"
);
}
#[test]
fn extracted_109_azepane_is_not_a_case_a_instance() {
let target = mol_from_smiles("C1CCCC[C@H](N1)C").unwrap();
let rule = rr(
"extracted_109",
"[C:2]-[CH:1](-[NH:5]-[C:4])-[C:3]>>O=[C:1](-[C:2])-[C:3].[C:4]-[NH2:5]",
);
assert!(
detect_case_a(&target, &rule).is_empty(),
"extracted_109's defect is Case B (matched atom reachable from a different \
product's core only through unmatched substituents) plus a separate atom-count \
inflation issue, not a direct matched-matched undeclared bond -- detect_case_a \
must not misfire here"
);
}
#[test]
fn extracted_112_indanone_is_not_a_case_a_instance() {
let target = mol_from_smiles("c2ccc1CCC(c1c2)=O").unwrap();
let rule = rr(
"extracted_112",
"[C:2]-[C:1](=[O:3])-[c:5](:[c:4]):[c:6]>>Cl-[C:1](-[C:2])=[O:3].[c:4]:[cH:5]:[c:6]",
);
assert!(
detect_case_a(&target, &rule).is_empty(),
"extracted_112's defect is Case B (the fused-ring bond runs through an unmatched \
CH2 to a matched aromatic atom belonging to the OTHER product's core), not a \
direct matched-matched undeclared bond -- detect_case_a must not misfire here"
);
}
#[test]
fn co_aliphatic_cleavage_piperidinyl_carbamate_is_not_flagged() {
let target = mol_from_smiles("O=C(OC1CCCNC1)N").unwrap();
let rule = rr("co_aliphatic_cleavage", "[C:1][O:2]>>[C:1].[O:2]");
assert!(
detect_case_a(&target, &rule).is_empty(),
"a real, fully-declared aliphatic C-O cleavage must never be flagged"
);
}
#[test]
fn cc_single_cleavage_azepane_methane_is_not_flagged() {
let target = mol_from_smiles("C1CC[C@H](C)NC[C@@H]1C").unwrap();
let rule = rr("cc_single_cleavage", "[C:1][C:2]>>[C:1].[C:2]");
assert!(
detect_case_a(&target, &rule).is_empty(),
"a fully-declared single-bond cleavage must never be flagged"
);
}
#[test]
fn ordinary_intermolecular_amide_formation_is_not_flagged() {
let target = mol_from_smiles("CC(=O)NC").unwrap(); let rule = rr(
"amide_formation_retro",
"[C:1](=[O:2])-[N:3]>>[C:1](=[O:2])[OH].[N:3]",
);
assert!(
detect_case_a(&target, &rule).is_empty(),
"ordinary intermolecular amide retro must never be flagged"
);
}
#[test]
fn ordinary_reductive_amination_is_not_flagged() {
let target = mol_from_smiles("CC(C)NC").unwrap(); let rule = rr(
"reductive_amination_retro",
"[C:1](-[C:4])(-[C:5])-[N:2]-[C:3]>>O=[C:1](-[C:4])-[C:5].[N:2]-[C:3]",
);
assert!(
detect_case_a(&target, &rule).is_empty(),
"ordinary reductive amination retro must never be flagged"
);
}
#[test]
fn legitimate_ring_opening_with_fully_declared_bonds_is_not_flagged() {
let target = mol_from_smiles("OC1CCCCC1").unwrap();
let rule = rr("ring_open_retro", "[C:1][C:2]>>[C:1].[C:2]");
assert!(
detect_case_a(&target, &rule).is_empty(),
"a fully-declared ring-bond cleavage must never be flagged, even though it acts on a ring"
);
}
#[test]
fn unrelated_distant_ring_on_target_is_not_flagged() {
let target = mol_from_smiles("c1ccccc1CC(=O)NC").unwrap(); let rule = rr(
"amide_formation_retro",
"[C:1](=[O:2])-[N:3]>>[C:1](=[O:2])[OH].[N:3]",
);
assert!(
detect_case_a(&target, &rule).is_empty(),
"an unrelated ring elsewhere in the molecule must never trigger a false positive"
);
}
#[test]
fn suzuki_style_two_fragment_disconnection_is_not_flagged() {
let target = mol_from_smiles("c1ccc(cc1)-c1ccccc1").unwrap();
let rule = rr("suzuki_retro_smirks", "[c:1]-[c:2]>>[c:1].[c:2]");
assert!(
detect_case_a(&target, &rule).is_empty(),
"a real Suzuki-style two-fragment disconnection on separate rings must never be flagged"
);
}
#[test]
fn graph_based_rule_with_empty_smirks_returns_no_findings() {
let target = mol_from_smiles("CCO").unwrap();
let rule = rr("ester_cleavage", "");
assert!(detect_case_a(&target, &rule).is_empty());
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn detects_extracted_112_indanone_cross_product_chain() {
let target = mol_from_smiles("c2ccc1CCC(c1c2)=O").unwrap();
let rule = rr(
"extracted_112",
"[C:2]-[C:1](=[O:3])-[c:5](:[c:4]):[c:6]>>Cl-[C:1](-[C:2])=[O:3].[c:4]:[cH:5]:[c:6]",
);
let findings = detect_case_b(&target, &rule);
assert!(
!findings.is_empty(),
"must detect the C2..c6 chain running through the unmatched ring-closing CH2"
);
assert_eq!(
findings[0].case,
SpectatorBondLossCase::CrossProductTerritory
);
}
#[test]
fn detects_extracted_109_azepane_cross_product_chain() {
let target = mol_from_smiles("C1CCCC[C@H](N1)C").unwrap();
let rule = rr(
"extracted_109",
"[C:2]-[CH:1](-[NH:5]-[C:4])-[C:3]>>O=[C:1](-[C:2])-[C:3].[C:4]-[NH2:5]",
);
let findings = detect_case_b(&target, &rule);
assert!(
!findings.is_empty(),
"must detect the C3..C4 chain running through the ring's unmatched carbons"
);
}
#[test]
fn extracted_4255_difluorophthalide_is_not_a_case_b_instance() {
let target = mol_from_smiles("C2c1cc(c(F)cc1C(O2)=O)F").unwrap();
let rule = rr(
"extracted_4255",
"[C:2]-[O:3]-[CH2:1]-[c:5](:[c:4]):[c:6]>>O=[CH2:1].[C:2]-[OH:3].[c:4]:[cH:5]:[c:6]",
);
assert!(
detect_case_b(&target, &rule).is_empty(),
"the map2-map6 bond is direct, not routed through an unmatched atom -- detect_case_b \
must defer to detect_case_a here, not double-report the same defect"
);
}
#[test]
fn co_aliphatic_cleavage_is_not_flagged_by_case_b() {
let target = mol_from_smiles("O=C(OC1CCCNC1)N").unwrap();
let rule = rr("co_aliphatic_cleavage", "[C:1][O:2]>>[C:1].[O:2]");
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn cc_single_cleavage_is_not_flagged_by_case_b() {
let target = mol_from_smiles("C1CC[C@H](C)NC[C@@H]1C").unwrap();
let rule = rr("cc_single_cleavage", "[C:1][C:2]>>[C:1].[C:2]");
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn ordinary_intermolecular_amide_formation_is_not_flagged_by_case_b() {
let target = mol_from_smiles("CC(=O)NC").unwrap();
let rule = rr(
"amide_formation_retro",
"[C:1](=[O:2])-[N:3]>>[C:1](=[O:2])[OH].[N:3]",
);
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn ordinary_reductive_amination_is_not_flagged_by_case_b() {
let target = mol_from_smiles("CC(C)NC").unwrap();
let rule = rr(
"reductive_amination_retro",
"[C:1](-[C:4])(-[C:5])-[N:2]-[C:3]>>O=[C:1](-[C:4])-[C:5].[N:2]-[C:3]",
);
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn legitimate_ring_opening_is_not_flagged_by_case_b() {
let target = mol_from_smiles("OC1CCCCC1").unwrap();
let rule = rr("ring_open_retro", "[C:1][C:2]>>[C:1].[C:2]");
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn unrelated_distant_ring_is_not_flagged_by_case_b() {
let target = mol_from_smiles("c1ccccc1CC(=O)NC").unwrap();
let rule = rr(
"amide_formation_retro",
"[C:1](=[O:2])-[N:3]>>[C:1](=[O:2])[OH].[N:3]",
);
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn suzuki_style_two_fragment_disconnection_is_not_flagged_by_case_b() {
let target = mol_from_smiles("c1ccc(cc1)-c1ccccc1").unwrap();
let rule = rr("suzuki_retro_smirks", "[c:1]-[c:2]>>[c:1].[c:2]");
assert!(detect_case_b(&target, &rule).is_empty());
}
#[test]
fn single_product_rhs_is_never_flagged_by_case_b() {
let target = mol_from_smiles("OC1CCCCC1").unwrap();
let rule = rr("dehydration_retro", "[C:1][O:2]>>[C:1][O:2]");
assert!(detect_case_b(&target, &rule).is_empty());
}
fn first_match_signature(rule: &RetroRule, target: &Molecule) -> Vec<String> {
let matches = find_reaction_matches(&rule.smirks, &[target]).unwrap();
replay_match_signature(&rule.smirks, target, &matches[0]).unwrap()
}
#[test]
fn gate_candidates_rejects_extracted_824() {
let target = mol_from_smiles("O=C2NCC(O2)Cc1ccccc1").unwrap();
let rule = rr(
"extracted_824",
"[C:5]-[O:6]-[C:3](=[O:4])-[NH:2]-[C:1]>>[C:1]-[N:2]=[C:3]=[O:4].[C:5]-[OH:6]",
);
let sig = first_match_signature(&rule, &target);
let verdicts = gate_candidates(&target, &rule, &[sig]);
assert!(
matches!(verdicts[0], SpectatorBondGateVerdict::Rejected { .. }),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_rejects_extracted_109() {
let target = mol_from_smiles("C1CCCC[C@H](N1)C").unwrap();
let rule = rr(
"extracted_109",
"[C:2]-[CH:1](-[NH:5]-[C:4])-[C:3]>>O=[C:1](-[C:2])-[C:3].[C:4]-[NH2:5]",
);
let sig = first_match_signature(&rule, &target);
let verdicts = gate_candidates(&target, &rule, &[sig]);
assert!(
matches!(verdicts[0], SpectatorBondGateVerdict::Rejected { .. }),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_rejects_extracted_112() {
let target = mol_from_smiles("c2ccc1CCC(c1c2)=O").unwrap();
let rule = rr(
"extracted_112",
"[C:2]-[C:1](=[O:3])-[c:5](:[c:4]):[c:6]>>Cl-[C:1](-[C:2])=[O:3].[c:4]:[cH:5]:[c:6]",
);
let sig = first_match_signature(&rule, &target);
let verdicts = gate_candidates(&target, &rule, &[sig]);
assert!(
matches!(verdicts[0], SpectatorBondGateVerdict::Rejected { .. }),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_rejects_extracted_4255() {
let target = mol_from_smiles("C2c1cc(c(F)cc1C(O2)=O)F").unwrap();
let rule = rr(
"extracted_4255",
"[C:2]-[O:3]-[CH2:1]-[c:5](:[c:4]):[c:6]>>O=[CH2:1].[C:2]-[OH:3].[c:4]:[cH:5]:[c:6]",
);
let sig = first_match_signature(&rule, &target);
let verdicts = gate_candidates(&target, &rule, &[sig]);
assert!(
matches!(verdicts[0], SpectatorBondGateVerdict::Rejected { .. }),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_accepts_negative_controls() {
let cases = [
(
"O=C(OC1CCCNC1)N",
rr("co_aliphatic_cleavage", "[C:1][O:2]>>[C:1].[O:2]"),
),
(
"C1CC[C@H](C)NC[C@@H]1C",
rr("cc_single_cleavage", "[C:1][C:2]>>[C:1].[C:2]"),
),
(
"CC(=O)NC",
rr(
"amide_formation_retro",
"[C:1](=[O:2])-[N:3]>>[C:1](=[O:2])[OH].[N:3]",
),
),
(
"OC1CCCCC1",
rr("ring_open_retro", "[C:1][C:2]>>[C:1].[C:2]"),
),
];
for (smiles, rule) in cases {
let target = mol_from_smiles(smiles).unwrap();
let sig = first_match_signature(&rule, &target);
let verdicts = gate_candidates(&target, &rule, &[sig]);
assert!(
matches!(verdicts[0], SpectatorBondGateVerdict::Accepted),
"{} / {}: {:?}",
rule.name,
smiles,
verdicts[0]
);
}
}
#[test]
fn gate_candidates_not_evaluable_for_hash_atom_wildcard_rule() {
let target = mol_from_smiles("CCO").unwrap();
let rule = rr("hash_rule", "[#6:1]-[#8:2]>>[#6:1].[#8:2]");
let verdicts = gate_candidates(
&target,
&rule,
&[vec!["CC".to_string()], vec!["O".to_string()]],
);
assert_eq!(verdicts.len(), 2);
for v in &verdicts {
assert!(
matches!(
v,
SpectatorBondGateVerdict::NotEvaluable {
reason: "hash_atom_wildcard_rule"
}
),
"{v:?}"
);
}
}
#[test]
fn gate_candidates_not_evaluable_when_lhs_unparseable() {
let target = mol_from_smiles("CCO").unwrap();
let rule = rr("garbage_lhs", "not smiles at all !!>>[C:1].[C:2]");
let verdicts = gate_candidates(&target, &rule, &[vec!["C".to_string()]]);
assert!(
matches!(
verdicts[0],
SpectatorBondGateVerdict::NotEvaluable {
reason: "unparseable_declared_bonds"
}
),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_not_evaluable_when_signature_does_not_correlate_to_any_match() {
let target = mol_from_smiles("CCO").unwrap();
let rule = rr("co_aliphatic_cleavage", "[C:1][O:2]>>[C:1].[O:2]");
let verdicts = gate_candidates(
&target,
&rule,
&[vec!["totally-unrelated-signature".to_string()]],
);
assert!(
matches!(
verdicts[0],
SpectatorBondGateVerdict::NotEvaluable {
reason: "match_correlation_failed"
}
),
"{:?}",
verdicts[0]
);
}
#[test]
fn gate_candidates_mixed_outcome_rejects_only_the_defective_candidate() {
let target = mol_from_smiles("O=C2NCC(O2)Cc1ccccc1.COC(=O)NC").unwrap();
let rule = rr(
"extracted_824",
"[C:5]-[O:6]-[C:3](=[O:4])-[NH:2]-[C:1]>>[C:1]-[N:2]=[C:3]=[O:4].[C:5]-[OH:6]",
);
let matches = find_reaction_matches(&rule.smirks, &[&target]).unwrap();
assert!(
matches.len() >= 2,
"expected at least one match in each disconnected component"
);
let sigs: Vec<Vec<String>> = matches
.iter()
.map(|m| replay_match_signature(&rule.smirks, &target, m).unwrap())
.collect();
let verdicts = gate_candidates(&target, &rule, &sigs);
assert!(
verdicts
.iter()
.any(|v| matches!(v, SpectatorBondGateVerdict::Rejected { .. })),
"the ring instance must reject: {verdicts:?}"
);
assert!(
verdicts
.iter()
.any(|v| matches!(v, SpectatorBondGateVerdict::Accepted)),
"the open-chain instance must stay accepted: {verdicts:?}"
);
assert!(
!verdicts.iter().any(|v| matches!(
v,
SpectatorBondGateVerdict::NotEvaluable {
reason: "ambiguous_signature"
}
)),
"the two components' signatures must never collide: {verdicts:?}"
);
}
}