use chematic_core::Molecule;
use chematic_ff::MMFF94Type;
use crate::align::{align_coords, apply_alignment};
use crate::shape_descriptors::jacobi3;
#[derive(Debug)]
pub enum O3AError {
CoordinateMismatch,
TypeAssignment(chematic_ff::AssignError),
}
impl std::fmt::Display for O3AError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
O3AError::CoordinateMismatch => write!(f, "coords length does not match atom count"),
O3AError::TypeAssignment(e) => write!(f, "MMFF94 type assignment failed: {e}"),
}
}
}
impl std::error::Error for O3AError {}
const DIST_CUTOFF: f64 = 4.0; const MAX_REFINE_ITERS: usize = 20;
const SCORE_SIGMA: f64 = 1.0; const CONVERGENCE_EPS: f64 = 1e-4;
fn centroid(coords: &[[f64; 3]]) -> [f64; 3] {
let n = coords.len().max(1) as f64;
let mut c = [0.0; 3];
for p in coords {
for k in 0..3 {
c[k] += p[k];
}
}
for v in &mut c {
*v /= n;
}
c
}
fn covariance_tensor(coords: &[[f64; 3]], center: [f64; 3]) -> [[f64; 3]; 3] {
let mut t = [[0.0; 3]; 3];
for p in coords {
let x = p[0] - center[0];
let y = p[1] - center[1];
let z = p[2] - center[2];
t[0][0] += x * x;
t[1][1] += y * y;
t[2][2] += z * z;
t[0][1] += x * y;
t[0][2] += x * z;
t[1][2] += y * z;
}
t[1][0] = t[0][1];
t[2][0] = t[0][2];
t[2][1] = t[1][2];
t
}
fn det3(m: [[f64; 3]; 3]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
fn normalize_proper(mut axes: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
if det3(axes) < 0.0 {
for row in axes.iter_mut() {
row[2] *= -1.0;
}
}
axes
}
fn matmul3(a: [[f64; 3]; 3], b_transposed: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut r = [[0.0; 3]; 3];
for (row, r_row) in r.iter_mut().enumerate() {
for (col, r_val) in r_row.iter_mut().enumerate() {
*r_val = (0..3).map(|k| a[row][k] * b_transposed[col][k]).sum();
}
}
r
}
fn apply_rotation_translation(
coords: &[[f64; 3]],
center: [f64; 3],
rot: [[f64; 3]; 3],
target_center: [f64; 3],
) -> Vec<[f64; 3]> {
coords
.iter()
.map(|p| {
let cp = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let mut out = [0.0; 3];
for (row, out_val) in out.iter_mut().enumerate() {
*out_val = (0..3).map(|k| rot[row][k] * cp[k]).sum::<f64>() + target_center[row];
}
out
})
.collect()
}
fn overlap_score_paired(a: &[[f64; 3]], b: &[[f64; 3]]) -> f64 {
a.iter()
.zip(b)
.map(|(p, q)| {
let d2: f64 = (0..3).map(|k| (p[k] - q[k]).powi(2)).sum();
(-d2 / (2.0 * SCORE_SIGMA * SCORE_SIGMA)).exp()
})
.sum()
}
fn overlap_score(pairs: &[(usize, usize)], coords1: &[[f64; 3]], coords2: &[[f64; 3]]) -> f64 {
let a: Vec<[f64; 3]> = pairs.iter().map(|&(i, _)| coords1[i]).collect();
let b: Vec<[f64; 3]> = pairs.iter().map(|&(_, j)| coords2[j]).collect();
overlap_score_paired(&a, &b)
}
fn greedy_correspondence(
coords1: &[[f64; 3]],
types1: &[MMFF94Type],
coords2: &[[f64; 3]],
types2: &[MMFF94Type],
) -> Vec<(usize, usize)> {
let c1 = centroid(coords1);
let mut order: Vec<usize> = (0..coords1.len()).collect();
order.sort_by(|&a, &b| {
let da: f64 = (0..3).map(|k| (coords1[a][k] - c1[k]).powi(2)).sum();
let db: f64 = (0..3).map(|k| (coords1[b][k] - c1[k]).powi(2)).sum();
db.partial_cmp(&da)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
coords1[a]
.partial_cmp(&coords1[b])
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let mut used = vec![false; coords2.len()];
let mut pairs = Vec::new();
for i in order {
let mut best_j: Option<usize> = None;
let mut best_d2 = f64::INFINITY;
for j in 0..coords2.len() {
if used[j] || types1[i] != types2[j] {
continue;
}
let d2: f64 = (0..3)
.map(|k| (coords1[i][k] - coords2[j][k]).powi(2))
.sum();
let better = d2 < best_d2
|| (d2 == best_d2 && best_j.is_some_and(|bj| coords2[j] < coords2[bj]));
if better {
best_d2 = d2;
best_j = Some(j);
}
}
if let Some(j) = best_j
&& best_d2.sqrt() <= DIST_CUTOFF
{
used[j] = true;
pairs.push((i, j));
}
}
pairs
}
fn refine(
coords1: &[[f64; 3]],
types1: &[MMFF94Type],
seeded_coords2: &[[f64; 3]],
types2: &[MMFF94Type],
) -> (Vec<(usize, usize)>, f64) {
let mut current2 = seeded_coords2.to_vec();
let mut best_pairs: Vec<(usize, usize)> = Vec::new();
let mut best_score = f64::NEG_INFINITY;
let mut prev_score = f64::NEG_INFINITY;
for _ in 0..MAX_REFINE_ITERS {
let pairs = greedy_correspondence(coords1, types1, ¤t2, types2);
if pairs.is_empty() {
break;
}
let s = overlap_score(&pairs, coords1, ¤t2);
if s > best_score {
best_score = s;
best_pairs = pairs.clone();
}
if (s - prev_score).abs() < CONVERGENCE_EPS {
break;
}
prev_score = s;
let sub1: Vec<[f64; 3]> = pairs.iter().map(|&(i, _)| coords1[i]).collect();
let sub2: Vec<[f64; 3]> = pairs.iter().map(|&(_, j)| current2[j]).collect();
let result = align_coords(&sub1, &sub2);
current2 = apply_alignment(¤t2, &result);
}
(best_pairs, best_score)
}
const SEED_SIGNS: [[f64; 3]; 4] = [
[1.0, 1.0, 1.0],
[1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[-1.0, -1.0, 1.0],
];
pub(crate) fn correspondence_search(
mol1: &Molecule,
coords1: &[[f64; 3]],
mol2: &Molecule,
coords2: &[[f64; 3]],
) -> Result<Vec<(usize, usize)>, O3AError> {
if coords1.len() != mol1.atom_count() || coords2.len() != mol2.atom_count() {
return Err(O3AError::CoordinateMismatch);
}
let types1 = chematic_ff::assign_mmff94_types(mol1).map_err(O3AError::TypeAssignment)?;
let types2 = chematic_ff::assign_mmff94_types(mol2).map_err(O3AError::TypeAssignment)?;
let c1 = centroid(coords1);
let c2 = centroid(coords2);
let (_, axes1) = jacobi3(covariance_tensor(coords1, c1));
let (_, axes2) = jacobi3(covariance_tensor(coords2, c2));
let axes1 = normalize_proper(axes1);
let axes2 = normalize_proper(axes2);
let mut best_pairs: Vec<(usize, usize)> = Vec::new();
let mut best_score = f64::NEG_INFINITY;
for signs in &SEED_SIGNS {
let mut axes2_signed = axes2;
for row in axes2_signed.iter_mut() {
for (col, sign) in signs.iter().enumerate() {
row[col] *= sign;
}
}
let rot = matmul3(axes1, axes2_signed);
let seeded = apply_rotation_translation(coords2, c2, rot, c1);
let (pairs, score) = refine(coords1, &types1, &seeded, &types2);
if score > best_score {
best_score = score;
best_pairs = pairs;
}
}
Ok(best_pairs)
}
#[derive(Debug, Clone)]
pub struct O3AResult {
pub pairs: Vec<(usize, usize)>,
pub score: f64,
pub alignment: crate::align::AlignResult,
}
pub fn o3a_align(
mol1: &Molecule,
coords1: &[[f64; 3]],
mol2: &Molecule,
coords2: &[[f64; 3]],
) -> Result<O3AResult, O3AError> {
let pairs = correspondence_search(mol1, coords1, mol2, coords2)?;
let sub1: Vec<[f64; 3]> = pairs.iter().map(|&(i, _)| coords1[i]).collect();
let sub2: Vec<[f64; 3]> = pairs.iter().map(|&(_, j)| coords2[j]).collect();
let alignment = align_coords(&sub1, &sub2);
let aligned_sub2 = apply_alignment(&sub2, &alignment);
let score = overlap_score_paired(&sub1, &aligned_sub2);
Ok(O3AResult {
pairs,
score,
alignment,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
use std::collections::HashSet;
#[test]
fn greedy_correspondence_tie_break_is_content_based() {
let t = MMFF94Type::C_sp3;
let coords1 = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]];
let types1 = vec![t, t, t];
let coords2 = vec![[0.1, 0.0, 0.0], [10.0, 0.1, 0.0], [0.1, 10.0, 0.0]];
let types2 = vec![t, t, t];
let pairs_a = greedy_correspondence(&coords1, &types1, &coords2, &types2);
let mut coords1_swapped = coords1.clone();
coords1_swapped.swap(1, 2);
let mut types1_swapped = types1.clone();
types1_swapped.swap(1, 2);
let mut coords2_swapped = coords2.clone();
coords2_swapped.swap(1, 2);
let mut types2_swapped = types2.clone();
types2_swapped.swap(1, 2);
let pairs_b = greedy_correspondence(
&coords1_swapped,
&types1_swapped,
&coords2_swapped,
&types2_swapped,
);
let unswap = |idx: usize| -> usize {
match idx {
1 => 2,
2 => 1,
other => other,
}
};
let mut normalized_b: Vec<(usize, usize)> = pairs_b
.iter()
.map(|&(i, j)| (unswap(i), unswap(j)))
.collect();
let mut normalized_a = pairs_a.clone();
normalized_a.sort_unstable();
normalized_b.sort_unstable();
assert_eq!(
normalized_a, normalized_b,
"greedy_correspondence's pairing (by coordinate identity) must not depend on array order"
);
}
fn rotate_translate(coords: &[[f64; 3]]) -> Vec<[f64; 3]> {
coords
.iter()
.map(|p| [-p[1] + 5.0, p[0] - 3.0, p[2] + 2.0])
.collect()
}
#[test]
fn self_alignment_recovers_identity_correspondence() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let n = mol.atom_count();
let coords1: Vec<[f64; 3]> = (0..n)
.map(|i| {
let t = i as f64;
[t * 1.3, (t * 0.7).sin() * 2.0, (t * 0.3).cos() * 1.5]
})
.collect();
let coords2 = rotate_translate(&coords1);
let pairs = correspondence_search(&mol, &coords1, &mol, &coords2).unwrap();
assert_eq!(pairs.len(), n, "every atom should find its rotated self");
for (i, j) in &pairs {
assert_eq!(i, j, "self-alignment must recover the identity mapping");
}
}
#[test]
fn correspondence_is_injective() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let n = mol.atom_count();
let coords1: Vec<[f64; 3]> = (0..n)
.map(|i| {
let t = i as f64;
[t * 1.3, (t * 0.7).sin() * 2.0, (t * 0.3).cos() * 1.5]
})
.collect();
let coords2 = rotate_translate(&coords1);
let pairs = correspondence_search(&mol, &coords1, &mol, &coords2).unwrap();
let js: HashSet<usize> = pairs.iter().map(|&(_, j)| j).collect();
assert_eq!(js.len(), pairs.len(), "no mol2 atom should be used twice");
}
#[test]
fn small_hand_built_system_finds_expected_pairing() {
let mol = parse("CO").unwrap();
let coords1: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [1.4, 0.0, 0.0]];
let coords2 = rotate_translate(&coords1);
let pairs = correspondence_search(&mol, &coords1, &mol, &coords2).unwrap();
assert_eq!(pairs.len(), 2);
let mut sorted = pairs.clone();
sorted.sort();
assert_eq!(sorted, vec![(0, 0), (1, 1)]);
}
#[test]
fn coordinate_mismatch_is_rejected() {
let mol = parse("CO").unwrap();
let coords_short = vec![[0.0, 0.0, 0.0]];
let coords_ok = vec![[0.0, 0.0, 0.0], [1.4, 0.0, 0.0]];
let err = correspondence_search(&mol, &coords_short, &mol, &coords_ok).unwrap_err();
assert!(matches!(err, O3AError::CoordinateMismatch));
}
#[test]
fn real_conformer_self_alignment() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let coords = crate::dg::generate_coords(&mol);
let n = mol.atom_count();
let coords1: Vec<[f64; 3]> = (0..n)
.map(|i| {
let p = coords.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect();
let coords2 = rotate_translate(&coords1);
let pairs = correspondence_search(&mol, &coords1, &mol, &coords2).unwrap();
assert_eq!(pairs.len(), n, "every atom should find its rotated self");
for (i, j) in &pairs {
assert_eq!(
i, j,
"self-alignment on a real conformer must recover identity"
);
}
let sub1: Vec<[f64; 3]> = pairs.iter().map(|&(i, _)| coords1[i]).collect();
let sub2: Vec<[f64; 3]> = pairs.iter().map(|&(_, j)| coords2[j]).collect();
let result = align_coords(&sub1, &sub2);
assert!(result.rmsd < 1e-6, "rmsd={} should be ~0", result.rmsd);
}
#[test]
fn benzene_toluene_shared_ring_atoms_match() {
let benzene = parse("c1ccccc1").unwrap();
let toluene = parse("Cc1ccccc1").unwrap();
let bc = crate::dg::generate_coords(&benzene);
let tc = crate::dg::generate_coords(&toluene);
let bn = benzene.atom_count();
let tn = toluene.atom_count();
let coords1: Vec<[f64; 3]> = (0..bn)
.map(|i| {
let p = bc.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect();
let coords2: Vec<[f64; 3]> = (0..tn)
.map(|i| {
let p = tc.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect();
let pairs = correspondence_search(&benzene, &coords1, &toluene, &coords2).unwrap();
assert!(
pairs.len() >= 4,
"expected most of benzene's 6 ring atoms to find a match in toluene's ring, got {}",
pairs.len()
);
}
#[test]
fn unrelated_molecules_still_return_a_correspondence() {
let mol1 = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let mol2 = parse("c1ccccc1").unwrap();
let n1 = mol1.atom_count();
let n2 = mol2.atom_count();
let coords1: Vec<[f64; 3]> = (0..n1).map(|i| [i as f64 * 1.4, 0.0, 0.0]).collect();
let coords2: Vec<[f64; 3]> = (0..n2).map(|i| [i as f64 * 1.4, 0.5, 0.0]).collect();
let pairs = correspondence_search(&mol1, &coords1, &mol2, &coords2).unwrap();
for &(i, j) in &pairs {
assert!(i < n1);
assert!(j < n2);
}
}
fn coords3d_to_vec(coords: &crate::coords::Coords3D, n: usize) -> Vec<[f64; 3]> {
(0..n)
.map(|i| {
let p = coords.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect()
}
#[test]
fn o3a_align_self_rotation_recovers_zero_rmsd() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let n = mol.atom_count();
let coords1 = coords3d_to_vec(&crate::dg::generate_coords(&mol), n);
let coords2 = rotate_translate(&coords1);
let result = o3a_align(&mol, &coords1, &mol, &coords2).unwrap();
assert_eq!(
result.pairs.len(),
n,
"every atom should find its rotated self"
);
for (i, j) in &result.pairs {
assert_eq!(i, j, "self-alignment must recover the identity mapping");
}
assert!(
result.alignment.rmsd < 1e-6,
"rmsd={} should be ~0",
result.alignment.rmsd
);
}
#[test]
fn o3a_align_rmsd_matches_direct_align_coords_for_known_correspondence() {
let mol = parse("CO").unwrap();
let coords1: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [1.4, 0.0, 0.0]];
let coords2 = rotate_translate(&coords1);
let result = o3a_align(&mol, &coords1, &mol, &coords2).unwrap();
let direct = align_coords(&coords1, &coords2);
assert!(
(result.alignment.rmsd - direct.rmsd).abs() < 1e-9,
"o3a_align rmsd {} should match direct align_coords rmsd {} for the same correspondence",
result.alignment.rmsd,
direct.rmsd
);
}
#[test]
fn o3a_align_scaffold_shared_pair_scores_higher_than_unrelated() {
let benzene = parse("c1ccccc1").unwrap();
let toluene = parse("Cc1ccccc1").unwrap();
let cyclohexane = parse("C1CCCCC1").unwrap();
let bc = coords3d_to_vec(&crate::dg::generate_coords(&benzene), benzene.atom_count());
let tc = coords3d_to_vec(&crate::dg::generate_coords(&toluene), toluene.atom_count());
let cc = coords3d_to_vec(
&crate::dg::generate_coords(&cyclohexane),
cyclohexane.atom_count(),
);
let related = o3a_align(&benzene, &bc, &toluene, &tc).unwrap();
let unrelated = o3a_align(&benzene, &bc, &cyclohexane, &cc).unwrap();
assert!(
related.score > unrelated.score,
"benzene/toluene (shared aromatic ring) score {} should exceed benzene/cyclohexane score {}",
related.score,
unrelated.score
);
}
#[test]
fn o3a_align_pairs_are_injective() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let n = mol.atom_count();
let coords1: Vec<[f64; 3]> = (0..n)
.map(|i| {
let t = i as f64;
[t * 1.3, (t * 0.7).sin() * 2.0, (t * 0.3).cos() * 1.5]
})
.collect();
let coords2 = rotate_translate(&coords1);
let result = o3a_align(&mol, &coords1, &mol, &coords2).unwrap();
let js: HashSet<usize> = result.pairs.iter().map(|&(_, j)| j).collect();
assert_eq!(
js.len(),
result.pairs.len(),
"no mol2 atom should be used twice"
);
}
fn permute_mol_and_coords(
mol: &Molecule,
coords: &[[f64; 3]],
perm: &[usize],
) -> (Molecule, Vec<[f64; 3]>) {
use chematic_core::{AtomIdx, MoleculeBuilder};
let mut old_to_new = vec![0u32; perm.len()];
for (new_idx, &old_idx) in perm.iter().enumerate() {
old_to_new[old_idx] = new_idx as u32;
}
let mut builder = MoleculeBuilder::new();
for &old_idx in perm {
builder.add_atom(mol.atom(AtomIdx(old_idx as u32)).clone());
}
for (_, bond) in mol.bonds() {
let a = AtomIdx(old_to_new[bond.atom1.0 as usize]);
let b = AtomIdx(old_to_new[bond.atom2.0 as usize]);
let _ = builder.add_bond(a, b, bond.order);
}
let permuted_coords: Vec<[f64; 3]> = perm.iter().map(|&old_idx| coords[old_idx]).collect();
(builder.build(), permuted_coords)
}
const ORDER_DEPENDENCE_CORPUS: &[&str] = &[
"CC(=O)Oc1ccccc1C(=O)O", "c1ccccc1",
"Cc1ccc(C)cc1",
"CC(=O)CC(=O)C",
"CC(C)(C)C",
"CC(C)(C)OC(=O)N",
"c1ccc2ccccc2c1",
"CC(C)(C)c1ccc(C(C)(C)C)cc1",
"O=C1CCC(=O)N1",
"CC(C)(C)C(=O)C(C)(C)C",
];
struct OrderDependenceProbe {
mol1: Molecule,
coords1: Vec<[f64; 3]>,
mol2: Molecule,
coords2: Vec<[f64; 3]>,
mol2_rev: Molecule,
coords2_rev: Vec<[f64; 3]>,
baseline_pairs: Vec<(usize, usize)>,
reversed_pairs: Vec<(usize, usize)>,
}
fn self_align_baseline_vs_reversed(smi: &str) -> OrderDependenceProbe {
let mol1 = parse(smi).unwrap();
let n = mol1.atom_count();
let coords1_map = crate::dg::generate_coords(&mol1);
let coords1: Vec<[f64; 3]> = (0..n)
.map(|i| {
let p = coords1_map.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect();
let mol2 = parse(smi).unwrap();
let coords2: Vec<[f64; 3]> = rotate_translate(&coords1);
let baseline = correspondence_search(&mol1, &coords1, &mol2, &coords2).unwrap();
let perm: Vec<usize> = (0..n).rev().collect();
let (mol2_rev, coords2_rev) = permute_mol_and_coords(&mol2, &coords2, &perm);
let reversed = correspondence_search(&mol1, &coords1, &mol2_rev, &coords2_rev).unwrap();
let mut reversed_pairs: Vec<(usize, usize)> =
reversed.iter().map(|&(i, j)| (i, perm[j])).collect();
let mut baseline_pairs = baseline.clone();
baseline_pairs.sort_unstable();
reversed_pairs.sort_unstable();
OrderDependenceProbe {
mol1,
coords1,
mol2,
coords2,
mol2_rev,
coords2_rev,
baseline_pairs,
reversed_pairs,
}
}
#[test]
fn correspondence_search_alignment_score_is_order_independent() {
for &smi in ORDER_DEPENDENCE_CORPUS {
let p = self_align_baseline_vs_reversed(smi);
let base_score = o3a_align(&p.mol1, &p.coords1, &p.mol2, &p.coords2)
.unwrap()
.score;
let rev_score = o3a_align(&p.mol1, &p.coords1, &p.mol2_rev, &p.coords2_rev)
.unwrap()
.score;
assert!(
(base_score - rev_score).abs() < 1e-9,
"{smi}: alignment score depends on mol2 atom order: {base_score} vs {rev_score}"
);
}
}
#[test]
#[ignore = "confirmed real, reachable, and deliberately not fixed this \
round: correspondence_search/o3a_align's `.pairs` (the \
specific per-atom identity mapping, as opposed to the \
alignment score) depends on mol2's atom insertion order for \
symmetric molecules -- reversing mol2's atom order changes \
which physical atom each mol1 atom is paired with for \
p-xylene, di-tert-butylbenzene, and succinimide in this \
corpus (3/10). This is the greedy-algorithm order-sensitivity \
greedy_correspondence's doc comment describes as a known, \
unaddressed property (separate from the 9d521f7 tie-break \
fix, which does not touch this) -- now empirically confirmed \
reachable rather than just theoretical. Confirmed harmless \
for the practical use case (see \
correspondence_search_alignment_score_is_order_independent: \
the alignment SCORE is identical in every diverging case \
here, consistent with the divergence being an \
automorphism-equivalent tie), but `.pairs` itself is a \
public field and IS order-dependent for any caller that \
inspects specific atom identities rather than just the \
score. A real fix needs a global assignment algorithm \
(e.g. Hungarian matching), out of scope for this round."]
fn correspondence_search_pairs_identity_is_order_independent() {
for &smi in ORDER_DEPENDENCE_CORPUS {
let p = self_align_baseline_vs_reversed(smi);
assert_eq!(
p.baseline_pairs, p.reversed_pairs,
"{smi}: correspondence_search's pairing (by atom identity) \
changed when mol2's atom order was reversed"
);
}
}
}