Gate the reaction family choice on reaction direction and the radical before the label tie-break - #982
Open
calvinp0 wants to merge 1 commit into
Open
Conversation
calvinp0
force-pushed
the
feature_family_choice_gates
branch
from
August 13, 2026 21:06
247162f to
52d8001
Compare
calvinp0
force-pushed
the
feature_family_choice_gates
branch
from
August 13, 2026 21:29
52d8001 to
6d13f96
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## fix_family_determinism_and_pinning #982 +/- ##
===================================================================
Coverage 64.03% 64.04%
===================================================================
Files 119 119
Lines 39510 39539 +29
Branches 10260 10266 +6
===================================================================
+ Hits 25300 25322 +22
- Misses 11233 11241 +8
+ Partials 2977 2976 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
calvinp0
force-pushed
the
feature_family_choice_gates
branch
from
August 14, 2026 19:32
6d13f96 to
5392fd8
Compare
calvinp0
force-pushed
the
feature_family_choice_gates
branch
from
August 15, 2026 14:16
5392fd8 to
6a9d185
Compare
…ical When several families match a reaction, the first entry of the family match list decides which family ARC uses, so the family label order is the tie-break. That order is lexical within each tier, which systematically favours the concerted families: RMG names the pericyclic families early in ASCII and the radical addition families late. Order and filter the matches before the lexical order is consulted. A match discovered in the reverse direction is dropped whenever a forward match exists, since its atom label map belongs to the flipped reaction. If the reactants carry unpaired electrons, only the matches whose family recipe gains or loses a radical are kept, provided that leaves at least one and drops at least one. What remains is ordered by the number of bonds the recipe forms or breaks, then by the number of bonds it changes the order of, then by the previous label order. The direction gate needs no recipe, only the discovery direction, so run it before the recipes are read. A family whose groups.py cannot be parsed then costs only the radical gate and the bond-change ordering, which do need the recipes, instead of silently disabling the direction gate as well; that degradation is now reported as a warning rather than at debug level. Reading the recipes after the gate also skips the recipes of the matches it dropped. Stop asking for reverse-discovered matches in the linear TS adapter's wider family-set scan. A reverse-discovered r_label_map indexes the flipped reaction: measured on CH4 + OH, its BREAK_BOND pairs are absent from the reactant graph and present in the product graph. Every consumer reads it as reactant indices, including get_expected_changing_bonds() in this adapter and map_rxn(), which cuts the reactants with it. The adapter branches on discovered_in_reverse to pick a strategy but never translates the index space, so the flag requested matches it cannot use.
calvinp0
force-pushed
the
feature_family_choice_gates
branch
from
August 15, 2026 15:07
6a9d185 to
4aed863
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #979, which is stacked on #978. Read those first — #978 makes the wider family set reachable, #979 makes the resulting order deterministic, and this PR decides which of several matching families wins.
The problem
product_dicts[0]wins.What this PR does
New
prioritize_family_product_dicts, applied insideget_reaction_family_productsbefore the existing label-sort tie-break:Both gates only ever choose among families that already matched, so they cannot invent a mechanism — only pick between valid ones.
Why this order
formed/brokenalone leavesH_Abstraction(2, 0) tied with families that differ only inchanged.Contract change worth flagging
get_reaction_family_productspreviously returned every match; it now returns a filtered and ordered list, and the docstring says so. Measured onC=C[CH]CCC + CC=CCCC >> C=CC(CCC)C(C)[CH]CCCunder'all': 4 product dicts before (1 forwardR_Addition_MultipleBond+ 3 reverseRetroene), 1 after.discover_own_reverse_rxns_in_reverse.linear.pyis the one caller that passes that flag asTrueand it has dedicated handling fordiscovered_in_reverse; after this PR it no longer receives reverse-discovered entries whenever a forward match co-exists. That is intended — those entries are the ones whose label maps produce non-existent bonds — but it is a real change to a caller that opted in, and reviewers oflinear.pyshould know.R_Addition_MultipleBond/Retroeneis the single largest ambiguous combination in the benchmark pool (118 of the 281 ambiguous reactions), so the direction gate, not the radical gate, is the highest-volume change here.Why we think it is right
The benchmark data records which family each reaction was originally generated from, which gives an independent check over the 281 ambiguous reactions:
Intra_RH_Add_Endocyclicreproduces the product connectivity by shifting a hydrogen with the radical as a spectator, which is not the reaction anyone means.The 5 that move away are
Ketoenol→intra_H_migration, and they are correctThis is the one result a reviewer should not have to re-derive, so the reasoning is here in full:
Ketoenol/groups.pycontains nou1atom anywhere in its group tree. It is a closed-shell tautomerisation template, trained on closed-shell reactants, and it needs a separateKetone_To_Enolfamily for the reverse direction. All 5 of these reactants are delocalised doublets with the spin density on the H-acceptor carbon. A template with no radical atom matching a radical reaction is precisely the failure mode the radical gate exists to catch.changed, so the TS is the same and only the label moves.[CH2]C(=C)O), C0 and C2 are equivalent allyl termini, so H→C0 and H→C2 are the same 1,3-shift under a mirror. There the gate measurably wins:Ketoenol's atom map scrambles the four CH₂ hydrogens across both termini, giving formed 5 / broken 5, which no single imaginary mode can satisfy, against a clean 1 / 1 forintra_H_migration.Ketoenol/intra_H_migrationambiguities the record labels 5 asKetoenoland 3 asintra_H_migration, while 6 of the 8 give identical bonds. The record tracks which Lewis structure RMG enumerated first, not a chemical distinction.intra_H_migrationadditionally gains AutoTST, which is not registered forKetoenol.Chemistry review: passed, ship as written. Two non-blocking follow-ups it raised, neither introduced by this PR:
get_number_of_atoms_in_reaction_zonedrops 4→3 underintra_H_migration, andintra_H_migration's emptychangedlist will interact with the NMD family-recipe work when the two meet.Behaviour worth knowing
Disproportionation → H_Abstractionmoves 4 reactions from no TS adapters at all to heuristics/autotst/crest.Intra_2+2_cycloaddition_Cd → Intra_R_Add_*gains kinbot.Intra_R_Add_Endo/Exocyclic,Intra_RH_Add_Endo/Exocyclic,Cl_/H_Abstraction,H_Abstraction/Substitution_Oall tie on recipe counts and still fall through to the deterministic order, as intended.multiplicity > 1, so a singlet biradical does not trigger it.The direction gate runs before the recipes, and it outranks the reverse-discovery opt-in
Two things that an earlier revision of this PR got wrong, both fixed here.
The gates were silently abandoned by one unreadable
groups.py.prioritize_family_product_dictsread every candidate family's recipe first and, onFileNotFoundError/KeyError/ValueError/InvalidAdjacencyListError, returned the list untouched atlogger.debug. A correctness precondition that one unparsable file disables at debug level is not one. The salient asymmetry is that the direction gate needs no recipe at all — onlydiscovered_in_reverse. So it now runs first, and an unreadable recipe costs only the radical gate and the bond-change ordering, which genuinely do need the recipes; that degradation is logged atwarning. Running the gate first also means the recipes of the matches it drops are never read.linear.pywas asking for reverse-discovered matches it cannot use.arc/job/adapters/ts/linear.py:1738passeddiscover_own_reverse_rxns_in_reverse=Trueinto its wider family-set rescue. That flag is now inert — and not only in the obvious case. It admits onlyown_reversereverse matches, and "own reverse" means the same template matches the forward direction too, so a forward match always co-exists and the gate always fires. Measured on the base: H_Abstraction CH4 + OH goes 4 → 6 dicts with the flag,intra_H_migrationon[CH2]CCCgoes 4 → 10; with the gate, both stay at 4 either way.The gate is nevertheless right and the call site was wrong, because a reverse-discovered
r_label_mapindexes the flipped reaction:Every reverse-discovered
BREAK_BONDpair is absent from the reactant graph and present in the product graph — those are the H₂O O–H bonds. Every consumer reads that map as reactant indices:get_expected_changing_bonds()at six sites inlinear.py, andmap_rxn, which callsfind_all_breaking_bonds(r_direction=True)and then cuts the reactants with the result.arc/mapping/contains no reference todiscovered_in_reverseanywhere.linear.py's threediscovered_in_reversesites do not close that gap::246— a_PathContextdataclass field. Plumbing.:1676— a docstring claiming the split/cross classification is direction-agnostic. That claim is about a different axis: given indices already in the unimolecular species' space, graph membership tells you which bonds to stretch without knowing the direction. It says nothing about index space, and thein uni_bond_settest it describes is exactly what makes wrong-space indices quiet — they usually fail it and the path is dropped with no message. Corrected here rather than deleted.:3035—_strategy_ring_scissionuses the flag as a strategy trigger (discovered_in_reverse and bb and not fb), then consumesctx.bb[0]as a reactant-space bond without translating it.So the flag requested matches the adapter has no way to interpret, and the call now omits it. This matches
feature_nmd_family_recipe, which returnsNonefor reverse-discovered matches rather than emit wrong-index-space bonds, on the grounds that translating a reverse label map needs the very atom-mapping machinery the recipe path exists to bypass._strategy_ring_scissionis unaffected: the gate drops reverse matches only when a forward match exists, and a reverse-only reaction — which is the ring-scission case — keeps all of them.Checks
Rebased onto #979's head
9c3ca6aa, which itself now sits onorigin/mainat44a6b112; the whole stack is 0 commits behindmain. This PR's own contribution is byte-identical across that rebase — the added and removed lines ofgit diff <base> <head>are identical before and after; no hunk header, no content line, and no commit message character changed. The rebase auto-mergedarc/job/adapters/ts/linear.py, wheremain's TS-guess identity change (869a1e2e) and this PR's removal of thediscover_own_reverse_rxns_in_reversekwarg both survive.get_all_families()is sha256-identical acrossPYTHONHASHSEED0, 1, 2, 3, 7, 13.TestFamilyChoiceGates, each mutation-proved — removing either gate or the count tie-break fails a distinct test.test_a_radical_cyclization_is_not_labeled_as_a_cycloadditionexercises the real path end to end on pentadienyl cyclisation, whereIntra_2+2_cycloaddition_Cdsorts ahead ofIntra_R_Add_Exocyclicand the gate still resolves to the radical addition.warninglevel, and not reading a dropped match's recipe. The other two pin the decision end to end — allyl + propene keeps only the forwardR_Addition_MultipleBondover three reverseRetroenematches without the caller opting in, and CH4 + OH still drops both reverse matches with the caller opting in.linear_test.pyandarc/mapping/: 215 passed.origin/main(44a6b112), re-run on the current head4aed863b: 5 failed / 2761 passed / 36 skipped on the branch vs 5 failed / 2730 passed / 36 skipped onmain— the same 5 on both, allarc/job/adapters/torch_ani_test.py, an environment issue. 0 added, 0 removed. (The branch passes 31 more tests because the stack adds that many.) Under-n 6the branch additionally tripsscheduler_test.py::test_initialize_output_dict; that test is order-dependent and fails identically onorigin/mainwhen run in isolation, so it is a pre-existing xdist worker-distribution artefact, not a regression —mainat-n 8trips a different pair (conformers_test,ts_test) for the same reason. (test_arc_families_pathpasses here; it fails only when the worktree path lacks the stringARC.)Reuse before writing
Searched for an existing home for the direction check before touching it:
discovered_in_reverseacross the repo (onlyarc/common.py's dict comparison,linear.py's four sites, andfamily.pyitself), andarc/mapping/for any existing reverse-aware label-map translation — there is none, which is the finding that decided this. No new helper was added; the fix is a reordering, a log level, and a removed keyword argument.