@@ -1068,3 +1068,160 @@ def test_ring_breaking_intrascale_m338():
10681068 assert intra1 .get (idx_i , idx_j ).lj () == pytest .approx (
10691069 ref_intra1 .get (idx_i , idx_j ).lj ()
10701070 ), f"intra1 lj mismatch at ({ i } ,{ j } )"
1071+
1072+
1073+ @pytest .mark .skipif (
1074+ not has_antechamber or not has_tleap ,
1075+ reason = "Requires antechamber and tLEaP to be installed." ,
1076+ )
1077+ @pytest .mark .skipif (
1078+ not has_openff ,
1079+ reason = "Requires OpenFF to be installed." ,
1080+ )
1081+ def test_ring_breaking_cross_bond_cleanup ():
1082+ """
1083+ Test that bonded terms spanning a ring-breaking bond are removed from the
1084+ end-state properties where that bond is absent (SYK 5035→5033).
1085+
1086+ In this perturbation a ring opens, leaving one bond present at λ=0 but
1087+ absent at λ=1. Any angle, dihedral or improper whose geometry depends on
1088+ that bond must be removed from the λ=1 properties; retaining them would
1089+ constrain atoms toward a bonded geometry that no longer exists and cause
1090+ large repulsion at the nonbonded/bonded lambda boundary.
1091+ """
1092+
1093+ # MCS mapping: {5033_idx: 5035_idx} — mol0=5033 (ring present), mol1=5035
1094+ # (ring absent), so the ring bond appears in connectivity0 but not
1095+ # connectivity1, giving ring_breaking = {(1, 7)} in the merged molecule.
1096+ mapping = {
1097+ 6 : 0 ,
1098+ 5 : 1 ,
1099+ 4 : 2 ,
1100+ 3 : 3 ,
1101+ 34 : 4 ,
1102+ 8 : 5 ,
1103+ 32 : 6 ,
1104+ 31 : 7 ,
1105+ 11 : 8 ,
1106+ 12 : 9 ,
1107+ 13 : 10 ,
1108+ 14 : 11 ,
1109+ 15 : 12 ,
1110+ 16 : 13 ,
1111+ 17 : 14 ,
1112+ 18 : 15 ,
1113+ 19 : 16 ,
1114+ 20 : 17 ,
1115+ 21 : 18 ,
1116+ 22 : 19 ,
1117+ 23 : 20 ,
1118+ 24 : 21 ,
1119+ 25 : 22 ,
1120+ 26 : 23 ,
1121+ 27 : 24 ,
1122+ 28 : 25 ,
1123+ 29 : 26 ,
1124+ 30 : 27 ,
1125+ 10 : 28 ,
1126+ 9 : 29 ,
1127+ 7 : 30 ,
1128+ 38 : 31 ,
1129+ 37 : 32 ,
1130+ 35 : 33 ,
1131+ 36 : 34 ,
1132+ 1 : 35 ,
1133+ 33 : 36 ,
1134+ 53 : 38 ,
1135+ 52 : 39 ,
1136+ 43 : 40 ,
1137+ 44 : 41 ,
1138+ 45 : 42 ,
1139+ 46 : 43 ,
1140+ 47 : 44 ,
1141+ 48 : 45 ,
1142+ 49 : 46 ,
1143+ 50 : 47 ,
1144+ 51 : 48 ,
1145+ 42 : 49 ,
1146+ 41 : 50 ,
1147+ }
1148+
1149+ mol0 = BSS .Parameters .openff_unconstrained_2_2_1 (
1150+ BSS .IO .readMolecules (f"{ url } /5033.sdf" )[0 ]
1151+ ).getMolecule ()
1152+ mol1 = BSS .Parameters .openff_unconstrained_2_2_1 (
1153+ BSS .IO .readMolecules (f"{ url } /5035.sdf" )[0 ]
1154+ ).getMolecule ()
1155+
1156+ mol0_aligned = BSS .Align .rmsdAlign (mol0 , mol1 , mapping )
1157+ merged = BSS .Align .merge (
1158+ mol0_aligned ,
1159+ mol1 ,
1160+ mapping ,
1161+ allow_ring_breaking = True ,
1162+ )
1163+
1164+ sire_mol = merged ._sire_object
1165+ mol_info = sire_mol .info ()
1166+
1167+ conn0 = sire_mol .property ("connectivity0" )
1168+ conn1 = sire_mol .property ("connectivity1" )
1169+
1170+ bonds0 = {
1171+ (
1172+ min (b .atom0 ().value (), b .atom1 ().value ()),
1173+ max (b .atom0 ().value (), b .atom1 ().value ()),
1174+ )
1175+ for b in conn0 .get_bonds ()
1176+ }
1177+ bonds1 = {
1178+ (
1179+ min (b .atom0 ().value (), b .atom1 ().value ()),
1180+ max (b .atom0 ().value (), b .atom1 ().value ()),
1181+ )
1182+ for b in conn1 .get_bonds ()
1183+ }
1184+
1185+ # Bonds present only at λ=1 must not appear in angle0/dihedral0/improper0.
1186+ ring_making = bonds1 - bonds0
1187+ # Bonds present only at λ=0 must not appear in angle1/dihedral1/improper1.
1188+ ring_breaking = bonds0 - bonds1
1189+
1190+ # This perturbation must have at least one ring-breaking bond.
1191+ assert ring_breaking , "Expected ring-breaking bonds in SYK 5035→5033"
1192+
1193+ for changing , suffix in [(ring_making , "0" ), (ring_breaking , "1" )]:
1194+ if not changing :
1195+ continue
1196+
1197+ for p in sire_mol .property (f"angle{ suffix } " ).potentials ():
1198+ i = mol_info .atom_idx (p .atom0 ()).value ()
1199+ j = mol_info .atom_idx (p .atom1 ()).value ()
1200+ k = mol_info .atom_idx (p .atom2 ()).value ()
1201+ assert (min (i , j ), max (i , j )) not in changing , (
1202+ f"angle{ suffix } ({ i } ,{ j } ,{ k } ) spans absent bond "
1203+ f"({ min (i , j )} ,{ max (i , j )} )"
1204+ )
1205+ assert (min (j , k ), max (j , k )) not in changing , (
1206+ f"angle{ suffix } ({ i } ,{ j } ,{ k } ) spans absent bond "
1207+ f"({ min (j , k )} ,{ max (j , k )} )"
1208+ )
1209+
1210+ for p in sire_mol .property (f"dihedral{ suffix } " ).potentials ():
1211+ j = mol_info .atom_idx (p .atom1 ()).value ()
1212+ k = mol_info .atom_idx (p .atom2 ()).value ()
1213+ assert (min (j , k ), max (j , k )) not in changing , (
1214+ f"dihedral{ suffix } central bond ({ j } ,{ k } ) spans absent bond"
1215+ )
1216+
1217+ for p in sire_mol .property (f"improper{ suffix } " ).potentials ():
1218+ atoms = {
1219+ mol_info .atom_idx (p .atom0 ()).value (),
1220+ mol_info .atom_idx (p .atom1 ()).value (),
1221+ mol_info .atom_idx (p .atom2 ()).value (),
1222+ mol_info .atom_idx (p .atom3 ()).value (),
1223+ }
1224+ for a , b in changing :
1225+ assert not (a in atoms and b in atoms ), (
1226+ f"improper{ suffix } spans absent bond ({ a } ,{ b } )"
1227+ )
0 commit comments