perf(reaction): stop generating 3D conformers just to count atoms (59x); allow execute_command to time out - #1003
perf(reaction): stop generating 3D conformers just to count atoms (59x); allow execute_command to time out#1003alongd wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR optimizes reaction atom-balance checks and adds optional process-group timeouts to execute_command().
Changes:
- Avoids unnecessary 3D conformer generation during atom counting.
- Defers conformer generation for atom mapping.
- Adds SIGTERM/SIGKILL timeout handling and tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Summary |
|---|---|
arc/reaction/reaction.py |
Optimizes atom-balance processing and lazy atom mapping. Critical issues remain for timeout cleanup interaction and polyatomic mol_list-only species; downstream job-selection behavior also lacks regression coverage. |
arc/reaction/reaction_test.py |
Adds atom-balance and reaction behavior tests. |
arc/job/local.py |
Adds process-group timeout execution. Critical issue: context-manager cleanup may perform an unbounded wait after timeout. |
arc/job/local_test.py |
Adds timeout and backward-compatibility tests. |
Suppressed comments (3)
arc/job/local.py:54
- This
Raisesentry says erroneous commands raiseSettingsError, but bothsubprocess.run()and the newPopenpath run withoutcheck=True; as the comment below acknowledges, a non-zero exit status never raisesCalledProcessErrorand is returned as output. The documented error contract should describe the only live error path here (timeouts), or the implementation should actually check return codes.
Raises:
SettingsError: If the command is erroneous or timed out, and ``no_fail`` is ``False``.
arc/reaction/reaction.py:161
atom_mapis a property, but this read can now run RDKit embedding/force-field generation and mutate every reaction species lacking coordinates. The current one-line docstring does not expose that potentially expensive and failure-prone side effect, so callers can unexpectedly trigger conformer generation merely by inspecting the map. Please document this behavior (or make mapping/conformer generation explicit).
and all(species.get_xyz(generate=True) is not None for species in self.r_species + self.p_species):
arc/reaction/reaction.py:1123
- The new graph fallback changes an observable failure path: for a non-TS species with a graph but no coordinates,
get_xyz(generate=True)can returnNoneafterget_cheap_conformer()andgenerate_conformers(n_confs=1)produce no conformers (seearc/species/species.py:1266-1273). The old code then emptied the well and skipped the balance check, whereas this helper constructs a well and can raiseReactionErrorfor an otherwise previously accepted reaction. Please preserve or explicitly handle this conformer-failure case rather than treating every graph as a successful replacement.
mol = species.mol if species.mol is not None else (species.mol_list[0] if species.mol_list else None)
if mol is None or not len(mol.atoms):
return ''
return '\n'.join(f'{atom.element.symbol} 0.0 0.0 0.0' for atom in mol.atoms)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
c55a397 to
e88b2d3
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1003 +/- ##
==========================================
+ Coverage 64.55% 64.60% +0.04%
==========================================
Files 119 119
Lines 39788 39843 +55
Branches 10307 10314 +7
==========================================
+ Hits 25684 25739 +55
- Misses 11123 11129 +6
+ Partials 2981 2975 -6
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:
|
8704af8 to
db639e7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Suppressed comments (5)
arc/job/local.py:205
- After the unconditional
grace_periodsleep, this wait can block for another fullgrace_periodwhen the direct child cannot be reaped. The timeout path can therefore taketimeout + 2 * grace_period(up to 10 seconds beyond the deadline), rather than thetimeout + 5 secondsbound stated in the PR description. Please avoid a second blocking grace period or document the actual upper bound.
try:
process.wait(timeout=grace_period)
except subprocess.TimeoutExpired:
arc/job/local_test.py:139
- The PID-writing grandchild is started asynchronously, but the whole setup is given only three seconds before the timeout fires. On a loaded CI worker the process can be killed before it reaches the file write, making the assertion at line 140 fail even when process-group cleanup is correct. Synchronize on the PID file (or make the shell wait for it) before entering the long sleep.
command = f'{sys.executable} {script_path} {pid_path} & sleep 300'
try:
with self.assertRaises(SettingsError):
local.execute_command(command, timeout=3)
arc/reaction/reaction.py:707
- This change removes the constructor's geometry side effect, which flips existing scheduler/conformer paths for graph-only polyatomics: with both
optandconf_optfalse,run_conformer_jobs()is now entered, andconsider_all_diastereomers=Falseno longer receives the embedded xyz. Those paths can change conformer and QM-job counts, but the added tests only inspect species state; please add scheduler-level coverage that asserts the selected jobs and diastereomer inputs for these configurations.
entry = _get_atom_balance_entry(species=reactant)
if entry:
r_well += (entry + '\n') * count
arc/reaction/reaction.py:168
- When
get_xyz(generate=True)cannot produce a geometry, thisall(...)stays false and_atom_mapremainsNone. Every subsequent read ofatom_maptherefore retriesget_cheap_conformer()andgenerate_conformers()for the same unembeddable species, repeatedly paying the failed RDKit path instead of failing cheaply. Cache the failed attempt or add a failure sentinel so the property does not repeat the generation.
and all(species.get_xyz(generate=True) is not None for species in self.r_species + self.p_species):
arc/reaction/reaction.py:705
- By removing the constructor-time conformer, graph-only reaction species with
force_field == 'cheap'now enterScheduler.run_conformer_jobs()underopt=False, conf_opt=False. That branch setsinitial_xyzfromget_xyz()and thenprocess_conformers()skips its job-spawn block becauseinitial_xyzis already set, so requested freq/SP jobs are never submitted; the old reaction path did submit them from the seeded geometry. Please preserve the downstream scheduling or update the cheap path to continue with non-opt jobs.
entry = _get_atom_balance_entry(species=reactant)
db639e7 to
d6209fc
Compare
…ting 3D conformers just to count atoms (59x); allow execute_command to time out
…ting 3D conformers just to count atoms (59x); allow execute_command to time out
…ting 3D conformers just to count atoms (59x); allow execute_command to time out
``ARCReaction.check_atom_balance()`` built its reactant and product wells from
``species.get_xyz(generate=True)``, which embeds and MMFF-optimizes a full 3D
conformer with RDKit for every species that has no coordinates yet. An atom
balance check only counts element symbols and never uses the coordinates, so the
geometry was generated and thrown away.
Read the element symbols off the species' 2D graph instead when no geometry is
already available. The new ``_get_atom_balance_entry()`` mirrors the conditions of
``ARCSpecies.get_xyz(generate=True)`` exactly - a TS only ever reports the
coordinates of its TS guesses, the ``mol_list`` fallback matches that method's
``self.mol is not None or self.mol_list is not None`` guard, and a species with
neither coordinates nor a graph still yields an empty well.
The *verdict* of check_atom_balance() is therefore unchanged: a generated
conformer is embedded from the very same 2D graph and always carries identical
element counts. Verified over ~51 species with zero divergence.
This is not a behaviour-free change, though. ``get_cheap_conformer()`` also
writes ``initial_xyz``/``final_xyz`` for monoatomics, so ``ARCReaction.__init__``
used to put geometry on every reaction species and on every H/O/N/Cl. Reaction
species no longer carry an MMFF ``cheap_conformer`` after construction; under
``job_types: {opt: false, conf_opt: false}`` and under
``consider_all_diastereomers: false`` they now take the same path standalone
species already take on main, which can change how many QM jobs a run spawns; and
``as_dict()``/restart YAML no longer carry ``cheap_conformer``. See the PR body.
``atom_map`` relied on that side effect: its ``get_xyz(generate=False)`` guard was
only ever satisfied because the constructor had already generated a conformer. It
now generates the coordinates itself, so the cost is paid where the geometry is
actually used - which makes reading the property a side-effecting operation.
Building the 1460 reactions of a real RMG mechanism (103 species): 26.7 s -> 1.2 s,
with ``check_atom_balance`` itself down from 26.66 s to 0.34 s.
``test_as_dict`` asserted on the ``cheap_conformer`` strings that the old
generation left behind; those RDKit-version-dependent expectations are dropped.
``execute_command()`` takes no deadline, so a command that never returns parks its caller forever. Add an optional ``timeout``; with the default ``None`` the call is byte-for-byte what it was. This commit does not fix anything on its own: none of the 24 call sites passes a ``timeout``, and none is given one here, deliberately - long quantum chemistry runs must not acquire a deadline as a side effect. What lands is the infrastructure that makes a deadline possible, for a follow-up to use. When a timeout is given the command runs in its own session and the timeout kills the whole process group, not just the direct child. ``proc.kill()`` alone is not enough: with ``shell=True`` the direct child is a shell that commonly ``exec``s its last command, so anything it backgrounded survives as an orphan - the "Terminate orphan process: pid (...)" seen in cancelled downstream CI runs. SIGTERM is sent first, SIGKILL after a grace period. The escalation is deliberately not conditioned on the direct child having survived SIGTERM, because the direct child is typically a shell that dies on it while the grandchild that ignores it is exactly the process that gets orphaned. The child is also deliberately not reaped before the SIGKILL: it is the group leader, so leaving it unreaped keeps its pid from being recycled and keeps the pgid pinned to this group. A timeout is reported the way a terminal failure already is: a warning and ``(None, None)`` when ``no_fail`` is ``True``, a ``SettingsError`` otherwise. It is not fed through the 30-attempt retry loop, which would defeat the deadline that was asked for - and which is in any case dead code, since ``subprocess.run()`` is called without ``check=True`` and so never raises ``CalledProcessError``. That is now noted in a comment; the new ``TimeoutExpired`` handler is the only live one.
d6209fc to
fc58df1
Compare
Two changes
1.
check_atom_balance()no longer builds 3D geometriesARCReaction.check_atom_balance()built its reactant and product wells fromspecies.get_xyz(generate=True). For any species without coordinates that forcesget_cheap_conformer()→embed_rdkit()+rdkit_force_field()(MMFF94s) — a full 3D embeddingand force-field optimisation, purely to obtain a list of element symbols.
The new private
_get_atom_balance_entry()returns the xyz string of an already availablegeometry, and otherwise reads element symbols off the species' 2D graph with placeholder
coordinates. It mirrors the conditions of
ARCSpecies.get_xyz(generate=True): coordinates first,a TS never falls back to its graph, the
mol_listfallback matches that method'sself.mol is not None or self.mol_list is not Noneguard, and a species with neither coordinatesnor a graph still empties the well and skips the check.
Building the 1460 reactions of a real 103-species RMG mechanism: 26.7 s → 1.2 s wall;
check_atom_balanceitself 26.66 s → 0.34 s bycProfile; RDKit is never entered. Reproducedindependently at 228.3 ms/rxn → 3.9 ms/rxn (59×) on 15 reactions with fresh species.
2.
execute_command()can take atimeoutOptional
timeout: float | None = None. WithNone— the default — the call is byte-for-byte whatit was. When a timeout is given, the command runs in its own session and the timeout kills the whole
process group, SIGTERM then SIGKILL.
The verdict of
check_atom_balance()is unchangedA generated conformer is embedded from the very same 2D graph, so it always carries identical
element counts. Verified over ~51 species — deuterated and tritiated, cubane, prismane,
tetrahedrane, a 20-membered ring, trans-cyclooctene, cations and anions, Si, S8, noble gases,
adjlist-defined species, atomic C — with zero divergence. A 16-case oracle over every species
shape that can reach the function (including no-
mol, no-graph-and-no-coordinates, resonance,TS-in-well, duplicate species, label-only reactions) returns byte-identical verdicts and exception
types on base and head.
But this is NOT a behaviour-free change — four real changes
ARCReaction.__init__used to write an MMFFcheap_conformeronto every polyatomic reactionspecies. State diverges from
mainimmediately after construction and re-converges exactly whenatom_mapis first read, so the exposure window is__init__→ firstatom_mapaccess — andScheduler.__init__sits inside it.Monoatomics are the exception, and the table below was previously wrong about them.
ARCSpecies.__init__calls_set_final_xyz_for_monoatomic()(arc/species/species.py:559, addedby
57a55de25), which populatesfinal_xyz— and hencecheap_conformer— for every monoatomicat construction, before any reaction exists. That commit landed after the
d033cbbfdbaselinethe original measurement was taken against, so monoatomics are unaffected by this PR.
Measured on base vs head, same species:
(a) Polyatomic reaction species no longer carry an MMFF
cheap_conformerafter construction.Monoatomics are unchanged: they still receive
cheap_conformer/initial_xyz/final_xyzatconstruction, from
_set_final_xyz_for_monoatomic().initial_xyz/final_xyzwere never set forpolyatomics on either side, so the only thing this PR drops is
cheap_conformeron polyatomics.test_check_atom_balance_does_not_seed_coordinatesdocuments the monoatomic exception.(b) Under
job_types: {opt: false, conf_opt: false}with no user xyz, reaction species now takethe conformer-generation path instead of running freq/sp on the MMFF geometry.
arc/scheduler.py:481gates onspecies.get_xyz(generate=False) and not job_types['conf_opt'] and not job_types['opt'], andarc/scheduler.py:1309gates conformer generation onget_xyz(generate=False) is None. Both flip.(c) Under
consider_all_diastereomers: falsewith no user xyz, the diastereomer filter is nolonger pinned to whatever chirality RDKit's
randomSeed=1embedding happened to produce.arc/species/species.py:1183takesxyz = self.get_xyz(generate=False)and passes[xyz]as thediastereomer to match; that xyz is now
None.(b) and (c) can change how many QM jobs a run spawns — the thing most worth a maintainer's
attention. Both make reaction species behave the way standalone species already behave on
main,so both are arguably bug fixes rather than regressions, but they are behaviour changes either way.
(d)
as_dict()and restart YAML no longer carrycheap_conformer— for polyatomics. Amonoatomic's
as_dict()still carries it at head, for the same reason as (a).test_as_dictasserted on those exact RDKit-version-dependent strings; the eight dropped expectations are all
polyatomic entries. Restart files remain compatible in both directions:
from_dict()guards theread with
if 'cheap_conformer' in species_dict.atom_mapis now a property with a side effectARCReaction.atom_maprelied on the conformer the constructor left behind: itsget_xyz(generate=False)guard was only ever satisfied because generation had already happened.It now uses
generate=True, so reading the property generates conformers. Same predicate(still
Nonewhen a species has neither graph nor coordinates), and the cost moves to where thegeometry is actually used. Without this, six mapping/NMD tests fail.
Residual divergence — constructible, and it is the PR's second bug fix
Reclassified after review: this is a bug fix, not a risk. The case was reachable after all —
ARCSpecies(smiles='C12C3C4C5C1C6C4C3C25C6'), a strained C10 cage that RDKit sanitizes and embedsbut which neither MMFF94s nor UFF can optimise, so
get_xyz(generate=True)returnsNone.Measured on a genuinely imbalanced reaction built from it (
C10H11on the left,C10H10on theright):
On base the balance check is skipped (
get_xyz(generate=True)isNone, theif xyz is not None and xyz:guard fails,break) and the method returnsTrue— ARC accepts anunbalanced reaction and proceeds to compute a rate coefficient for it, silently. On head
_get_atom_balance_entry()falls back to the 2D graph, the check runs, and it raises at reactionconstruction, before any QM job is submitted.
So the direction is the opposite of a risk: the abort only fires when the reaction really is
imbalanced, and head cannot raise for a genuinely balanced one (the element counts are identical
between the two paths). The class is narrow — highly strained polycyclics and atom types outside
MMFF/UFF coverage; a 19-species oracle including cubane, prismane, tetrahedrane, a 20-membered ring
and trans-cyclooctene did not reach it — but automated mechanism work enumerates thousands of
species with no human reading each one. TODO: add that cage species as a regression test. It is
the only known input where base and head give different verdicts, and it is currently untested.
No call site passes a
timeoutAll 24 callers pass none, and none is given one here — long QC runs must not acquire a deadline as
a side effect of this PR. The orphan bug is therefore not fixed by this PR, only made fixable.
_run_with_timeout,_kill_process_groupand the SIGKILL escalation are infrastructure for afollow-up that actually sets deadlines.
Two details worth knowing. The escalation is deliberately not conditioned on the direct child
having survived SIGTERM: with
shell=Truethe direct child is a shell that dies on SIGTERM, whilea grandchild that ignores it is exactly the process that gets orphaned. And the child is
deliberately not reaped before the SIGKILL: it is the group leader, so leaving it unreaped keeps
its pid from being recycled and keeps the pgid pinned to this group. A timing-out call is bounded
by
timeout+ the 5 s grace period.The
except subprocess.CalledProcessErrorhandler and the 30-attempt retry loop it drives aredead code —
subprocess.run()is called withoutcheck=Trueand so never raises it. Nownoted in a comment. The new
TimeoutExpiredhandler is the only live one.Review items — disposition
_kill_process_groupmol_list[0]fallback) survived_get_atom_balance_entry_run_with_timeoutdocstringcommandtypeItem 1 — the proposed guard would have reintroduced item 2
The recycling defect is real: my code reaped the child inside the SIGTERM branch, freeing the
group-leader pid, and then SIGKILLed that pgid.
The proposed fix,
if process.poll() is None:, does not work, because the direct child does dieon SIGTERM. Probed directly:
So that guard skips the escalation in exactly the case item 2 asks me to pin. Run against the
rebuilt test, it fails:
The same probe shows what actually closes the window: the unreaped child pins the pgid.
The child is the group leader, so while it is unreaped its pid cannot be recycled and
pgidstaysbound to this group. The fix is therefore to stop reaping before the SIGKILL —
time.sleep(grace_period)instead ofprocess.wait(timeout=grace_period), then SIGKILL, thenreap. Recycling window closed, escalation preserved. Cost: a timing-out call now always takes the
full grace period.
Items 2 and 3 — mutation results
Rebuilt test: the command backgrounds a Python grandchild that installs
signal.SIG_IGNforSIGTERM and blocks, while the direct child is a plain
sleepthat dies on SIGTERM.process.kill()instead of the groupif process.poll() is None:guardmol = species.mol,mol_list[0]fallback droppedtest_get_atom_balance_entry, 1 failed / 3 passedOn item 3 I kept the fallback rather than removing it. Inside ARC the
mol is None and mol_list is not Nonestate is indeed unreachable —mol_listis only ever set byset_mol_list(),which requires
self.mol is not None(arc/species/species.py:1077);as_dict()writes amol_listkey (:796) thatfrom_dict()never reads back; and the onlyself.mol = Nonein ARCis
from_dict()at:936, which leavesmol_listat its__init__value ofNone. But the stateis reachable through the public attribute, and
ARCSpecies.get_xyz()itself contemplates it(
elif generate and (self.mol is not None or self.mol_list is not None),:1266), withget_cheap_conformer()serving monoatomics and diatomics frommol_list[0](:1213,:1217).Dropping the fallback would therefore make the helper diverge from
get_xyzin a stateget_xyzexplicitly handles. The new test reaches it both directly and through a full reaction.
Item 5 — the annotation is right; I improved the docstring instead
_run_with_timeoutcannot receive astr.execute_command()normalises before the retry loop:Widening the annotation to
str | list[str]would document a call that cannot happen. I keptlist[str]and made the docstring say why it is a list ("already normalized byexecute_command()into the single-element list thatsubprocessexpects"), which addresses thereadability concern without making the type less accurate. Say the word if you want it widened
anyway.
Also done
git clean -fd— removedarc/testing/test_GaussianAdapter/, and alsoarc/testing/test_CFourAdapter/andarc/testing/test_xTBAdapter_1/, which the suite createsthe same way. Not committed, not gitignored. Working tree is clean.
CalledProcessErrorhandler and its retry loop as dead code.Tests
Baseline re-established from scratch (all four touched files restored to
d033cbbfdin place),because an intermediate scratch file had been clobbered and I did not want to trust it.
d033cbbfdFAILED node-ID sets: identical, zero new and zero resolved.
ERROR node-ID sets: identical, 72 = 72.
Subsets:
pytest arc/reaction/ -ra -q -p no:randomly→ 33 passed, 0 failedpytest arc/job/local_test.py -ra -q -p no:randomly→ 11 passed, 1 failed(
test_determine_job_id, pre-existing on base — localsettings.pyhascluster_soft: local)pytest arc/job/ -ra -q -n auto -p no:randomly→ 1105 passed, 15 failed, 37 errors — same set asbase, zero new
Net new tests: 7 (3 in
reaction_test.py, 4 inlocal_test.py), all passing.Re-verified after the review edits: benchmark 1.22 s (from 26.7 s), and the 16-case verdict oracle
still byte-identical between base and head.
Concerns
a run spawns under two specific configurations. Both align reaction species with standalone
species, but no functional test was run and I have not exercised a real scheduler run.
ReactionErrordirection of the residual divergence is the one failuremode that could abort a user's run at a place that never failed before. Unreached in ~51
attempts; I could not construct it.
atom_mapbeing side-effecting is a wart. It is the minimal fix, but a property thatsilently runs RDKit is a trap for the next reader; a follow-up might make it explicit.
execute_commandnow always waits the full 5 s grace. Short-circuiting wouldrequire reaping the child, which is exactly what reopens the pid-recycling window. No caller
passes a timeout, so nothing is affected today.
build_t3()in PyYAML, ~100 times. The remaining fixis on the T3 side and is not in this PR.
Fifth behaviour change (undeclared until now):
get_cheap_conformer()arc/species/species.py::get_cheap_conformer()is public API and changed here, so it belongs onthis list. It is strictly more robust in three directions, with no regression:
mol = self.mol if self.mol is not None else (self.mol_list[0] if self.mol_list else None)once and returns with a warning if that isNone; base raisedAttributeErrorin thepolyatomic branch.
self.mol_list[0]unconditionally and nowprefer
self.mol; for a species withmolset butmol_liststillNone, base raisedTypeError.self.molunconditionally and now falls back tomol_list[0], matchingget_xyz(), which contemplates that state atarc/species/species.py:1266.Description corrected 2026-08-22 following review. Changes: (a) and (d) were measured against
d033cbbfd, before_set_final_xyz_for_monoatomic()(57a55de25) landed, and overstated themonoatomic effect — corrected above; the residual divergence was reclassified from a risk to a bug
fix after the case was constructed; the fifth behaviour change was added. No code changed.