Skip to content

Commit fb488c6

Browse files
committed
fix(scheduler): make TS adapter spawning degrade gracefully instead of silently or fatally
Two failure modes in ``Scheduler.spawn_ts_jobs``, both found on the same campaign run, both costing work that had already succeeded. 1. No eligible adapter, silently. An adapter is spawned only if it is in BOTH the configured ``ts_adapters`` and ``ts_adapters_by_rmg_family[rxn.family]``. When those lists do not intersect the loop completes without spawning anything and says nothing. Nothing recovers: ``tsg_spawned`` is latched True before the loop, so the reaction is never revisited, and the TS guess report is written with ``successful_methods: []`` AND ``unsuccessful_methods: []`` -- both empty, because nothing was attempted. The only user-visible symptom is "TS did not converge", much later, which is indistinguishable from having tried every adapter and failed. Hit when ~/.arc/settings.py pinned ts_adapters = ['heuristics', 'AutoTST', 'GCN', 'xtb_gsm', 'orca_neb'] predating 'linear' joining ARC's own default in arc/settings/settings.py. The family 1,2_Insertion_CO admits only ['kinbot', 'goflow', 'rits', 'linear']. Empty intersection, zero jobs, no log line. Because arc/imports.py has the home settings override repo defaults key-by-key, a stale local file disables whole families this way. 2. An uninstalled optional adapter, fatally. Several TS adapters shell out to a separate conda env and repo checkout. KinBot and AutoTST signal "backend not installed" by raising FileNotFoundError out of ``execute_incore``. That propagated through run_job -> spawn_ts_jobs -> spawn_post_opt_jobs and terminated the entire ARC run. Observed cost: 'linear' reported Linear successfully found 7 TS guesses for s0_CH2O2 <=> s1_H2O + s2_CO. and then the run died on the next adapter in the list because KINBOT_PYTHON was unset. All 7 guesses, and every converged species job before them, were lost to a missing optional dependency. Record the unavailable adapter in ``unsuccessful_methods`` and continue -- the remaining adapters are exactly the redundancy that makes a multi-adapter TS search worth configuring. The catch is narrowed to FileNotFoundError so it cannot mask real defects.
1 parent 4b7e0e5 commit fb488c6

2 files changed

Lines changed: 244 additions & 8 deletions

File tree

arc/scheduler.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,10 +1829,9 @@ def spawn_ts_jobs(self):
18291829
logger.info(f'Not spawning TS search jobs for reaction {rxn} for which the multiplicity is unknown.')
18301830
else:
18311831
rxn.ts_species.tsg_spawned = True
1832-
tsg_index = 0
1832+
tsg_index, eligible_methods = 0, list()
1833+
family_known = rxn.family is not None and rxn.family in ts_adapters_by_rmg_family
18331834
for method in self.ts_adapters:
1834-
family_known = (rxn.family is not None
1835-
and rxn.family in ts_adapters_by_rmg_family)
18361835
admit_unknown_family = (not family_known
18371836
and method in ts_adapters_for_unknown_unimolecular
18381837
and rxn.is_unimolecular())
@@ -1844,12 +1843,70 @@ def spawn_ts_jobs(self):
18441843
logger.info(f'Admitting TS adapter {method!r} for reaction {rxn.label} '
18451844
f'via ts_adapters_for_unknown_unimolecular '
18461845
f'(RMG family is {rxn.family!r}).')
1847-
self.run_job(job_type='tsg',
1848-
job_adapter=method,
1849-
reactions=[rxn],
1850-
tsg=tsg_index,
1851-
)
1846+
eligible_methods.append(method)
1847+
try:
1848+
self.run_job(job_type='tsg',
1849+
job_adapter=method,
1850+
reactions=[rxn],
1851+
tsg=tsg_index,
1852+
)
1853+
except FileNotFoundError as e:
1854+
# Several TS adapters are optional and shell out to a separate conda
1855+
# env and repo checkout. KinBot (kinbot_ts.py) and AutoTST
1856+
# (autotst_ts.py) both signal "backend not installed" by raising
1857+
# FileNotFoundError out of execute_incore -- and that exception
1858+
# propagated through spawn_post_opt_jobs and terminated the whole ARC
1859+
# run. A single missing optional dependency therefore destroyed every
1860+
# result the run had already produced, including TS guesses other
1861+
# adapters had just found successfully.
1862+
#
1863+
# Record it as an unsuccessful method and carry on: the remaining
1864+
# adapters are exactly the redundancy that makes a multi-adapter TS
1865+
# search worth running.
1866+
logger.error(f'The {method!r} TS search adapter is not available and '
1867+
f'was skipped for reaction {rxn.label}: {e}')
1868+
if method not in rxn.ts_species.unsuccessful_methods:
1869+
rxn.ts_species.unsuccessful_methods.append(method)
1870+
# run_job() registers the job in running_jobs and in job_dict before
1871+
# calling job.execute(), which is what raises here. Roll that back:
1872+
# the entry describes a job that never ran, and both save_restart_dict()
1873+
# and the main loop resolve a 'tsg<i>' entry through job_dict, so a
1874+
# stale one is serialized and then parsed as a completed job.
1875+
# Freeing the index also lets the next adapter reuse it cleanly
1876+
# instead of appending a second entry under the same name.
1877+
if f'tsg{tsg_index}' in self.running_jobs.get(rxn.ts_label, list()):
1878+
self.running_jobs[rxn.ts_label].remove(f'tsg{tsg_index}')
1879+
self.job_dict.get(rxn.ts_label, dict()).get('tsg', dict()).pop(tsg_index, None)
1880+
continue
18521881
tsg_index += 1
1882+
if not tsg_index and not rxn.ts_species.ts_guesses:
1883+
# No adapter was eligible, and no guess was supplied by the user, so no TS
1884+
# guess will ever be produced for this reaction: ``tsg_spawned`` was latched
1885+
# True above, so this is not retried.
1886+
# Without this warning the condition is entirely silent -- the run reports
1887+
# only "TS did not converge" much later, which is indistinguishable from
1888+
# having tried every adapter and failed. The TS guess report is equally
1889+
# ambiguous: it comes back with successful_methods AND unsuccessful_methods
1890+
# both empty, because nothing was ever attempted.
1891+
eligible = ts_adapters_by_rmg_family.get(rxn.family) if family_known else None
1892+
if eligible_methods:
1893+
# The adapters were eligible, they just could not run: every one of them
1894+
# is an optional backend that is not installed on this machine.
1895+
reason = (f'all of its eligible adapters {eligible_methods} are unavailable '
1896+
f'on this machine (see the errors above). Install one of them, or add '
1897+
f'an eligible adapter that is installed')
1898+
else:
1899+
reason = (f'none of the configured ts_adapters {self.ts_adapters} is eligible for it. '
1900+
+ (f'Its RMG family {rxn.family!r} admits {eligible}; the two lists do '
1901+
f'not intersect.' if eligible is not None else
1902+
f'Its RMG family {rxn.family!r} is not in ts_adapters_by_rmg_family, '
1903+
f'and it did not qualify for {ts_adapters_for_unknown_unimolecular} '
1904+
f'(is_unimolecular={rxn.is_unimolecular()}).')
1905+
+ ' Add an eligible adapter to ts_adapters')
1906+
logger.warning(f'Not spawning any TS search job for reaction {rxn.label}: {reason} '
1907+
f'(in the input file or in ~/.arc/settings.py) to compute this TS. '
1908+
f'No TS guess will be generated and this reaction will be reported '
1909+
f'as not converged.')
18531910
if all('user guess' in tsg.method for tsg in rxn.ts_species.ts_guesses):
18541911
rxn.ts_species.tsg_spawned = True
18551912
self.run_conformer_jobs(labels=[rxn.ts_label])

arc/scheduler_test.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,10 +1775,50 @@ def tearDownClass(cls):
17751775
shutil.rmtree(project_directory, ignore_errors=True)
17761776

17771777

1778+
class StubJob(object):
1779+
"""
1780+
A stand-in for a job object, so that Scheduler.run_job() can be driven without executing anything.
1781+
A job of an unavailable adapter raises FileNotFoundError from execute(), as the KinBot and
1782+
AutoTST adapters do when their backend is not installed.
1783+
"""
1784+
1785+
def __init__(self, job_type: str, job_adapter: str | None, unavailable: bool = False):
1786+
self.job_type = job_type
1787+
self.job_adapter = job_adapter
1788+
self.unavailable = unavailable
1789+
self.job_name = f'{job_type}_{job_adapter}'
1790+
self.job_id = None
1791+
self.server = None
1792+
1793+
def as_dict(self) -> dict:
1794+
"""Return a dictionary representation of the job, used when saving the restart file."""
1795+
return {'job_type': self.job_type, 'job_adapter': self.job_adapter}
1796+
1797+
def execute(self) -> None:
1798+
"""Execute the job, raising if its external backend is not installed."""
1799+
if self.unavailable:
1800+
raise FileNotFoundError(f'The {self.job_adapter} python executable was not found.')
1801+
1802+
17781803
class TestSpawnTsJobsAdmission(unittest.TestCase):
17791804
"""
17801805
Contains unit tests for the TS adapter admission logic of Scheduler.spawn_ts_jobs().
17811806
"""
1807+
@classmethod
1808+
def setUpClass(cls):
1809+
"""
1810+
A method that is run before all unit tests in this class.
1811+
"""
1812+
cls.ess_settings = {'gaussian': ['server1']}
1813+
cls.projects = [f'arc_project_for_testing_delete_after_usage_tsg_{i}' for i in range(1, 6)]
1814+
1815+
@classmethod
1816+
def tearDownClass(cls):
1817+
"""
1818+
A method that is run after all unit tests in this class, deleting the project directories.
1819+
"""
1820+
for project in cls.projects:
1821+
shutil.rmtree(os.path.join(ARC_PATH, 'Projects', project), ignore_errors=True)
17821822

17831823
def test_spawn_ts_jobs_unknown_family_admission_predicate(self):
17841824
"""Test the admission predicate for TS adapters of reactions with an unknown family."""
@@ -1819,6 +1859,145 @@ def test_spawn_ts_jobs_unknown_family_admission_predicate(self):
18191859
and rxn.is_unimolecular())
18201860
self.assertEqual(admit_unknown_family, expected_admission)
18211861

1862+
def setup_hocho_scheduler(self,
1863+
ts_adapters: list,
1864+
project: str,
1865+
unavailable_adapters: list | None = None,
1866+
) -> tuple:
1867+
"""
1868+
Set up a Scheduler holding HOCHO <=> H2O + CO (family 1,2_Insertion_CO) with optimized wells.
1869+
1870+
Only ``job_factory()`` is replaced, by one handing back ``StubJob``, so that
1871+
``spawn_ts_jobs()`` can be driven end to end without executing anything while ``run_job()``
1872+
still does its real bookkeeping. A job for an adapter named in ``unavailable_adapters``
1873+
raises ``FileNotFoundError`` from ``execute()``, exactly as the KinBot and AutoTST adapters
1874+
do when their backend is not installed -- that is, after ``run_job()`` has already
1875+
registered the job in ``running_jobs`` and in ``job_dict``.
1876+
1877+
Args:
1878+
ts_adapters (list): The TS adapters to configure the Scheduler with.
1879+
project (str): The project name, also the project directory name.
1880+
unavailable_adapters (list, optional): Adapters whose job raises ``FileNotFoundError``.
1881+
1882+
Returns:
1883+
tuple: The Scheduler, its ARCReaction, and the list accumulating the
1884+
(job type, adapter) pairs jobs were created for.
1885+
"""
1886+
unavailable_adapters = unavailable_adapters or list()
1887+
r_species = [ARCSpecies(label='HOCHO', smiles='OC=O')]
1888+
p_species = [ARCSpecies(label='H2O', smiles='O'), ARCSpecies(label='CO', smiles='[C-]#[O+]')]
1889+
for spc in r_species + p_species:
1890+
spc.final_xyz = spc.get_xyz() # Marks the well as optimized, gating spawn_ts_jobs().
1891+
rxn = ARCReaction(r_species=r_species, p_species=p_species)
1892+
self.assertEqual(rxn.family, '1,2_Insertion_CO')
1893+
sched = Scheduler(project=project,
1894+
ess_settings=self.ess_settings,
1895+
species_list=r_species + p_species,
1896+
rxn_list=[rxn],
1897+
project_directory=os.path.join(ARC_PATH, 'Projects', project),
1898+
ts_adapters=ts_adapters,
1899+
job_types=initialize_job_types(),
1900+
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
1901+
opt_level=Level(repr=default_levels_of_theory['opt']),
1902+
testing=True,
1903+
)
1904+
spawned = list()
1905+
1906+
def stub_job_factory(job_type, job_adapter=None, **kwargs):
1907+
"""Record the job being created and hand back a StubJob instead of a real job."""
1908+
spawned.append((job_type, job_adapter))
1909+
return StubJob(job_type=job_type, job_adapter=job_adapter,
1910+
unavailable=job_adapter in unavailable_adapters)
1911+
1912+
patcher = patch('arc.scheduler.job_factory', new=stub_job_factory)
1913+
patcher.start()
1914+
self.addCleanup(patcher.stop)
1915+
return sched, rxn, spawned
1916+
1917+
def test_spawn_ts_jobs_warns_when_no_adapter_is_eligible(self):
1918+
"""Test that a reaction with no eligible configured adapter is reported, not skipped silently.
1919+
1920+
Regression test for a real campaign failure: ``~/.arc/settings.py`` pinned
1921+
``ts_adapters = ['heuristics', 'AutoTST', 'GCN', 'xtb_gsm', 'orca_neb']`` (predating 'linear'
1922+
joining ARC's own default list), while the reaction's family, ``1,2_Insertion_CO``, admits
1923+
only ``['kinbot', 'goflow', 'rits', 'linear']``. The intersection was empty, so zero TS guess
1924+
jobs were spawned -- silently. ``tsg_spawned`` latches True regardless, so nothing retried,
1925+
and the TS guess report came back with successful_methods AND unsuccessful_methods both
1926+
empty. The only user-visible symptom was 'TS did not converge', hours later.
1927+
"""
1928+
configured = ['heuristics', 'autotst', 'gcn', 'xtb_gsm', 'orca_neb']
1929+
sched, rxn, spawned = self.setup_hocho_scheduler(ts_adapters=configured,
1930+
project='arc_project_for_testing_delete_after_usage_tsg_1')
1931+
eligible = [a.lower() for a in ts_adapters_by_rmg_family[rxn.family]]
1932+
self.assertEqual([m for m in configured if m in eligible], list())
1933+
self.assertIn('linear', eligible) # 'linear' bridges them, a correctly-configured run is unaffected.
1934+
1935+
with self.assertLogs('arc', level='WARNING') as captured:
1936+
sched.spawn_ts_jobs()
1937+
self.assertEqual([adapter for job_type, adapter in spawned if job_type == 'tsg'], list())
1938+
self.assertTrue(any('Not spawning any TS search job' in record.getMessage()
1939+
and rxn.family in record.getMessage() for record in captured.records))
1940+
self.assertTrue(rxn.ts_species.tsg_spawned) # Latched, hence the single chance to report this.
1941+
1942+
def test_spawn_ts_jobs_warns_when_ts_adapters_is_empty(self):
1943+
"""Test that an empty ts_adapters list warns instead of raising."""
1944+
sched, rxn, spawned = self.setup_hocho_scheduler(ts_adapters=list(),
1945+
project='arc_project_for_testing_delete_after_usage_tsg_2')
1946+
with self.assertLogs('arc', level='WARNING') as captured:
1947+
sched.spawn_ts_jobs()
1948+
self.assertEqual([adapter for job_type, adapter in spawned if job_type == 'tsg'], list())
1949+
self.assertTrue(any('Not spawning any TS search job' in record.getMessage()
1950+
for record in captured.records))
1951+
1952+
def test_spawn_ts_jobs_does_not_warn_when_a_user_guess_exists(self):
1953+
"""Test that a user-supplied TS guess suppresses the 'no TS guess will be generated' warning."""
1954+
sched, rxn, spawned = self.setup_hocho_scheduler(ts_adapters=['heuristics'],
1955+
project='arc_project_for_testing_delete_after_usage_tsg_3')
1956+
rxn.ts_species.ts_guesses.append(TSGuess(method='user guess 0',
1957+
xyz=rxn.r_species[0].get_xyz(),
1958+
index=0))
1959+
with self.assertNoLogs('arc', level='WARNING'):
1960+
sched.spawn_ts_jobs()
1961+
self.assertEqual([adapter for job_type, adapter in spawned if job_type == 'tsg'], list())
1962+
self.assertTrue(len(spawned)) # The user guess itself does proceed, so nothing is lost here.
1963+
self.assertEqual(len(rxn.ts_species.ts_guesses), 1)
1964+
1965+
def test_spawn_ts_jobs_survives_an_uninstalled_optional_adapter(self):
1966+
"""Test that an adapter whose backend is missing is recorded and skipped, not fatal.
1967+
1968+
Regression test for a real campaign failure. ``ts_adapters`` listed 'kinbot', whose backend
1969+
is optional and not installed on that host, so ``KinBotAdapter.execute_incore`` raised
1970+
``FileNotFoundError('The KinBot python executable was not found...')``. That propagated
1971+
through ``run_job`` -> ``spawn_ts_jobs`` -> ``spawn_post_opt_jobs`` and terminated the whole
1972+
ARC run -- discarding the 7 TS guesses 'linear' had successfully produced for the same
1973+
reaction moments earlier. A missing optional dependency must cost that adapter, not the run.
1974+
"""
1975+
sched, rxn, spawned = self.setup_hocho_scheduler(ts_adapters=['kinbot', 'linear'],
1976+
project='arc_project_for_testing_delete_after_usage_tsg_4',
1977+
unavailable_adapters=['kinbot'])
1978+
sched.spawn_ts_jobs() # Must not raise: that is the whole point.
1979+
# 'linear' still ran, the run was not lost.
1980+
self.assertEqual([adapter for job_type, adapter in spawned if job_type == 'tsg'], ['kinbot', 'linear'])
1981+
self.assertEqual(rxn.ts_species.unsuccessful_methods, ['kinbot'])
1982+
# run_job() registers a job before executing it, so the failed adapter left bookkeeping behind
1983+
# that must be rolled back: exactly one 'tsg0' entry, holding the job that actually ran.
1984+
self.assertEqual(sched.running_jobs[rxn.ts_label], ['tsg0'])
1985+
self.assertEqual(list(sched.job_dict[rxn.ts_label]['tsg'].keys()), [0])
1986+
self.assertEqual(sched.job_dict[rxn.ts_label]['tsg'][0].job_adapter, 'linear')
1987+
1988+
def test_spawn_ts_jobs_warns_when_every_eligible_adapter_is_unavailable(self):
1989+
"""Test that a reaction whose every eligible adapter is uninstalled is reported, and leaves no state."""
1990+
sched, rxn, spawned = self.setup_hocho_scheduler(ts_adapters=['kinbot', 'linear'],
1991+
project='arc_project_for_testing_delete_after_usage_tsg_5',
1992+
unavailable_adapters=['kinbot', 'linear'])
1993+
with self.assertLogs('arc', level='WARNING') as captured:
1994+
sched.spawn_ts_jobs()
1995+
self.assertEqual(rxn.ts_species.unsuccessful_methods, ['kinbot', 'linear'])
1996+
self.assertTrue(any("all of its eligible adapters ['kinbot', 'linear'] are unavailable" in record.getMessage()
1997+
for record in captured.records))
1998+
self.assertEqual(sched.running_jobs.get(rxn.ts_label, list()), list())
1999+
self.assertEqual(sched.job_dict[rxn.ts_label].get('tsg', dict()), dict())
2000+
18222001

18232002
class TestSchedulerAdaptiveReactionLevels(unittest.TestCase):
18242003
"""

0 commit comments

Comments
 (0)