Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion arc/mapping/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def try_mapping(r: ARCReaction) -> list[int] | None:
except ValueError:
return None
if flip:
raw_map = try_mapping(rxn.flip_reaction())
raw_map = try_mapping(prepare_flipped_reaction(rxn))
if raw_map is None:
return None
return check_atom_map_and_return(flip_map(raw_map))
Expand All @@ -80,6 +80,52 @@ def try_mapping(r: ARCReaction) -> list[int] | None:
return check_atom_map_and_return(raw_map)


def prepare_flipped_reaction(rxn: ARCReaction) -> ARCReaction:
"""
Build the flipped reaction and give it a usable, forward-discovered family template.

``ARCReaction.flip_reaction`` resets the family, so the flipped copy re-derives its product dictionaries
lazily with the *default* family set. When the original reaction was matched only by a broader set - the
usual situation for a template discovered in reverse - that rederivation comes back empty and the flip
retry has nothing to map with. Widen the search in that case, and prefer a template that describes the
flipped reaction forward.

Args:
rxn (ARCReaction): The reaction to flip.

Returns:
ARCReaction: The flipped reaction, seeded with a family and product dictionaries where possible.
"""
flipped = rxn.flip_reaction()
try:
product_dicts = flipped.product_dicts or list()
except (ValueError, KeyError, AttributeError):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we log these at debug? get_reaction_family_products logs its onw skips that way so it'd match the neighbourhoo and right now a failed widening is indistinguishable from "this reaction has no family" until it surfaces as a generic could not be atom mapped much later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — a failed widening should be distinguishable from "no family at all". Moving to debug to match get_reaction_family_products.

product_dicts = list()
if all(pd.get('discovered_in_reverse') for pd in product_dicts):
try:
widened = flipped.get_product_dicts(rmg_family_set='all')
except (ValueError, KeyError, AttributeError):
widened = list()
logger.debug(f'Widened the family search for {flipped.label} to all families, '
f'got {len(widened)} product dictionaries.')
product_dicts = widened or product_dicts
forward_dicts = [pd for pd in product_dicts if not pd.get('discovered_in_reverse')]
product_dicts = forward_dicts or product_dicts
if product_dicts:
# Keep only the chosen family's dictionaries. get_reaction_family_products() concatenates matches
# across every family without grouping them, while ``family`` can only name one, so leaving the
# whole list in place lets map_rxn's product_dict_index retry pair one family's recipe with
# another's label map. Every family labels its atoms *1/*2/*3, so that mispairing resolves against
# the wrong atoms and still yields a permutation, which is all check_atom_map_and_return verifies.
# TODO: replace with ARCReaction.restrict_product_dicts_to_family() once #978 lands.
family = product_dicts[0]['family']
product_dicts = [pd for pd in product_dicts if pd['family'] == family]
flipped.product_dicts = product_dicts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flipped.family is taken from product_dicts[0], but the whole list is assigned and get_reaction_family_products concatenates matches across all 99 families with no grouping. If two families amtch, map_rxn's pdi retry pairs family A's recipe with family B's label map then reads product_dicts[pdi]['r_label_map']).

Both use *1/*2/*3, so it succeeds against the wrong atoms and check_atom_map_and_return only checks it's a permutation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and thank you — this is a genuine defect, not a nit. prepare_flipped_reaction assigns the whole list but takes family from product_dicts[0], so map_rxn's pdi retry can pair one family's recipe with another's r_label_map, and since both use *1/*2/*3 it succeeds against the wrong atoms exactly as you describe.

Measured over the 452-reaction corpus: 7 of 335 flipped reactions carry product_dicts spanning more than one family — e.g. C10H11 <=> C10H11-2 matching Intra_Diels_alder_monocyclic + Intra_R_Add_Endocyclic + Intra_R_Add_Exocyclic.
restrict_product_dicts_to_family() from #978 is precisely the right fix. Two options: I add an interim single-family restriction here so this can land independently, or I rebase onto #978 and call your method. I'd prefer the latter — one implementation, not two. Your call on ordering.

flipped.family = family
flipped.family_own_reverse = product_dicts[0]['own_reverse']
return flipped


def check_atom_map_and_return(atom_map: list[int] | None) -> list[int] | None:
"""
Check if the atom map is valid and return it.
Expand Down
43 changes: 43 additions & 0 deletions arc/mapping/driver_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,49 @@ def test_map_ho2_elimination_from_peroxy_radical(self):
self.assertIn(tuple(rxn_4.atom_map[5: 8]), list(permutations([3, 4, 5])))
self.assertEqual(rxn_4.atom_map[8:], [6, 9])

def test_prepare_flipped_reaction_seeds_a_forward_template(self):
"""Test that the flipped reaction is given a family that matches it in the forward direction.

``flip_reaction`` resets the family, so the flipped copy would otherwise re-derive its product
dictionaries with the default family set, come back empty, and leave the flip retry with nothing.
"""
rxn = ARCReaction(r_species=[ARCSpecies(label='C2H5Cl', smiles='CCCl')],
p_species=[ARCSpecies(label='C2H4', smiles='C=C'),
ARCSpecies(label='HCl', smiles='Cl')])
rxn.product_dicts = rxn.get_product_dicts(rmg_family_set='all')
flipped = prepare_flipped_reaction(rxn)
self.assertEqual(flipped.family, 'XY_Addition_MultipleBond')
self.assertTrue(flipped.product_dicts)
self.assertFalse(flipped.product_dicts[0]['discovered_in_reverse'])
# Only the chosen family's dictionaries survive, so a recipe can never be paired with another
# family's label map.
self.assertEqual({pd['family'] for pd in flipped.product_dicts}, {'XY_Addition_MultipleBond'})
# The template products of a forward discovery are isomorphic to that reaction's own products.
template_products = flipped.product_dicts[0]['products']
self.assertEqual(len(template_products), len(flipped.p_species))
self.assertTrue(any(flipped.p_species[0].is_isomorphic(mol) for mol in template_products))

def test_map_reaction_with_a_reverse_discovered_template(self):
"""Test mapping an elimination whose family only matches it in the addition direction.

``XY_Addition_MultipleBond`` matches HX elimination only in reverse, so the template's 'products'
are isomorphic to the reaction's reactants. Both ``get_template_product_order`` and
``reorder_p_label_map`` compare against the reaction's products, so the forward attempt fails until
the reaction is flipped first.
"""
rxn = ARCReaction(r_species=[ARCSpecies(label='C2H5Cl', smiles='CCCl')],
p_species=[ARCSpecies(label='C2H4', smiles='C=C'),
ARCSpecies(label='HCl', smiles='Cl')])
rxn.product_dicts = rxn.get_product_dicts(rmg_family_set='all')
rxn.family = rxn.product_dicts[0]['family']
rxn.family_own_reverse = rxn.product_dicts[0]['own_reverse']
self.assertEqual(rxn.family, 'XY_Addition_MultipleBond')
self.assertTrue(rxn.product_dicts[0]['discovered_in_reverse'])
atom_map = map_reaction(rxn=rxn, backend='ARC')
self.assertIsNotNone(atom_map)
self.assertEqual(sorted(atom_map), list(range(len(atom_map))))
self.assertTrue(check_atom_map(rxn))

def test_map_flipped_reaction(self):
"""Test the map_flipped_reaction() function."""
c2h5o3_xyz = {'coords': ((-1.3476727508427788, -0.49923624257482285, -0.3366372557370102),
Expand Down
Loading