Skip to content

CREST Adapter - #807

Open
calvinp0 wants to merge 266 commits into
mainfrom
crest_adapter
Open

CREST Adapter#807
calvinp0 wants to merge 266 commits into
mainfrom
crest_adapter

Conversation

@calvinp0

@calvinp0 calvinp0 commented Nov 27, 2025

Copy link
Copy Markdown
Member

Addition of CREST Adapter that complements the heuristic adapter.

This pull request adds support for the CREST conformer and transition state (TS) search method to the ARC project, along with several related improvements and code cleanups. The most important changes include integrating CREST as a TS search adapter, updating configuration and constants, and enhancing the heuristics TS search logic for better provenance tracking and code clarity.

CREST Integration:

  • Added CREST as a supported TS search method: updated JobEnum (arc/job/adapter.py), included CREST in the list of adapters and RMG family mapping, and registered it as a default incore adapter (arc/job/adapters/common.py, arc/job/adapters/ts/__init__.py). [1] [2] [3] [4]
  • Implemented a new test suite for CREST input generation (arc/job/adapters/ts/crest_test.py).
  • Added a Makefile target and installation script for CREST (Makefile). [1] [2]

Constants and Configuration:

  • Added the angstrom_to_bohr conversion constant to both Cython and Python constants modules (arc/constants.pxd, arc/constants.py). [1] [2] [3]

Heuristics TS Search Enhancements and Refactoring:

  • Refactored heuristics TS search logic to track and combine method provenance for TS guesses, allowing for more precise attribution when multiple methods contribute to a guess (arc/job/adapters/ts/heuristics.py). [1] [2] [3] [4]
  • Improved code readability and maintainability by reformatting imports and function calls, and clarifying data structures and comments in heuristics TS search (arc/job/adapters/ts/heuristics.py). [1] [2] [3] [4] [5] [6] [7]
    .

Comment thread arc/job/adapters/ts/crest.py Fixed
Comment thread arc/job/adapters/ts/crest.py Fixed
Comment thread arc/job/adapters/ts/crest.py Fixed
Comment thread arc/settings/settings.py Fixed
Comment thread arc/settings/settings.py Fixed
Comment thread arc/job/adapters/ts/autotst_ts.py Fixed
Comment thread arc/job/adapters/ts/crest.py Fixed
Comment thread arc/job/adapters/ts/heuristics.py Fixed
Comment thread arc/job/adapters/ts/heuristics.py Fixed
Comment thread arc/job/adapters/ts/heuristics.py Fixed
Comment thread arc/job/adapters/ts/crest.py Fixed

This comment was marked as resolved.

Comment thread arc/job/adapters/ts/crest.py Fixed

This comment was marked as resolved.

Comment thread arc/job/adapters/ts/heuristics.py Fixed
Comment thread arc/settings/settings.py Fixed
Comment thread arc/settings/settings.py Fixed
@calvinp0
calvinp0 force-pushed the crest_adapter branch 2 times, most recently from 3e88c36 to 7674f5f Compare February 2, 2026 09:49
@calvinp0
calvinp0 requested a review from Copilot February 2, 2026 12:10

This comment was marked as resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

This comment was marked as resolved.

@calvinp0
calvinp0 force-pushed the crest_adapter branch 2 times, most recently from 9be935e to eab7647 Compare February 4, 2026 20:14
@calvinp0
calvinp0 requested a review from alongd February 4, 2026 21:30
calvinp0 added 25 commits July 21, 2026 10:04
…ocks

determine_ess_status() mined the Orca SCF block for an initial/final energy
pair to run its divergence heuristic. Two ways that crashed on a normally
terminated run:

1. The scan for the first SCF iteration energy assumed the line immediately
   following the incremental-Fock marker holds one. Orca may print a SOSCF
   header and a separator in between, and the separator holds a single token,
   so `forward_lines[j + 1].split()[1]` raised IndexError. The loop was also
   unbounded and could only terminate by raising.

2. `scf_energy_last_iteration` was assigned only inside the 'TOTAL SCF ENERGY'
   branch but read whenever an initial energy was found, so a log with a
   parseable iteration line and no 'TOTAL SCF ENERGY' header raised
   UnboundLocalError.

Neither exception is caught by any caller, so either one aborts the whole run.

Cherry-picked from fix_orca_ess_status (b79d011).
determine_ess_status() dispatches on an exact software name. OrcaNEBAdapter
passed 'orca_neb', matched no branch, and fell through to the terminal
`return '', list(), '', ''`, so a normally terminated NEB run carried an empty
status rather than 'done'.

Since 35d9bd3 the tsg ingestion gate requires ess_succeeded for .log output,
so that empty status silently discarded every Orca NEB TS guess. Measured on
the 500-reaction benchmark: orca_neb contributed guesses in 10 of 16 reactions
before 2026-07-15 and in 0 of 15 after.

Override ess_software to 'orca' on OrcaNEBAdapter, and use it at the remaining
determine_ess_status call site that still passed job_adapter.

Note that Orca NEB now takes part in Orca's ESS error handling like any other
Orca job; in particular a 'Syntax' keyword raises JobError, which
Scheduler.end_job turns into a re-run.

Cherry-picked from fix_orca_ess_status (a0e1a6a).
test_determine_ess_status_orca_scf_energies_unavailable built its two logs as
inline strings. Replace them with fixtures derived from real Orca output:
arc/testing/neb/neb_res.out truncated at two points that leave the SCF block
incomplete, so both carry genuine Orca formatting (the SOSCF header and the
one-token S-O-S-C-F separator that caused the original IndexError) rather than
a hand-written approximation.

No log already under arc/testing/ exhibits either shape -- the corpus was
collected from runs that failed in interesting ways, and a run that stops
before emitting TOTAL SCF ENERGY looks like infrastructure noise -- so the
fixtures are derived rather than captured.

Each pins one half of the fix:

  neb_scf_no_iteration_line.out  marker + SOSCF header + column header, no
                                 numeric row. Pre-fix this raised IndexError on
                                 the separator; the bounded scan must instead
                                 run off the end without assigning an energy.

  neb_scf_no_total_energy.out    a parseable iteration row but no TOTAL SCF
                                 ENERGY block, so the initial energy is
                                 assigned and the final one is not. With the
                                 scan fixed but the guard reverted this still
                                 raises, so it isolates the guard.

Verified red with arc/job/trsh.py reverted to its pre-fix state, green with it
restored. Drops the _write_temp_log helper.
Two changes to the TS-guess completion path.

The ingestion gate added for QST2 requires a successful ESS run before a .log
output is read. QST2 and Orca NEB both write a file called input.log, so the
gate caught NEB as well -- but the two differ: a failed QST2 job has nothing
usable in its log, whereas NEB writes its geometry before the post-processing
that may then fail. Orca NEB contributed guesses in 10 of 16 benchmark
reactions before that gate and none of 15 after. Key the exemption on the
adapter rather than on a file extension several unrelated adapters share, so a
failed QST2 job stays excluded.

troubleshoot_ess already knows about tsg jobs -- it records un-troubleshootable
errors onto the TSGuess -- but no call site ever passed it one, so that branch
was unreachable and a guess lost to a recoverable failure was dropped with only
a warning. Call it for an errored TS-guess job, guarded on times_rerun so a
persistent failure is attempted once.
_select_ch3_anchors() promised a second anchor B forming a non-linear B-X-A
angle, but never tested for colinearity and never fell back to hydrogens.
For a linear heavy-atom skeleton (e.g., propyne, CH3-C#CH) every heavy
candidate is colinear with the X->A axis, so _construct_local_axes() raised
'Anchors are colinear; cannot define unique plane.', map_rxn() returned None,
and the whole reaction could not be atom mapped (surfaced by a Retroene
benchmark reaction, C=C=CCC <=> C#CC + C=C, once the species carried
optimized geometries).

Now anchor selection walks candidates in priority order (heavy neighbor of A,
any other neighbor of A, the XH3 hydrogens, any remaining atom) and returns
the first that passes the same non-colinearity criterion _construct_local_axes()
enforces, so selection can never hand it anchors it will reject.
_construct_local_axes() itself remains strict.

Also, _map_xh3_group() now rejects a (None, None) anchor tuple (a non-empty
tuple is truthy, so the previous guard missed it) and returns None instead of
propagating a ValueError, and map_hydrogens() falls back to an in-order
assignment of the three equivalent XH3 hydrogens when the geometric refinement
fails, so the resulting map stays a complete permutation.
(cherry picked from commit 79b771d on fix_job_log_quality)
(cherry picked from commits 1ef7d3d + 067649c on fix_job_log_quality)
(cherry picked from commit 15095d7 on fix_job_log_quality)
… and reduce memory

(cherry picked from commit fb4719a on feature_gaussian_trsh_remedies)
(cherry picked from commit 6d7181a on fix_job_log_quality)
Register 'crest' as a TS-search adapter for intra_NO2_ONO_conversion and add the
corresponding CREST constraint branch to the seed hub.

The family recipe is BREAK_BOND *1-*2 / FORM_BOND *1-*3, i.e. the carbon-nitrogen
bond breaks, the carbon-oxygen bond forms, and the nitrogen-oxygen bond is retained
while one nitro oxygen migrates onto the carbon. The constraint specification pins
all three distances plus the C-N-O angle, so CREST metadynamics samples spectator
conformations without tearing the nitro group apart.

The family produces no heuristic seeds, so CREST reaches it through the backup-seed
route, which carries no generator metadata. The recipe atoms are therefore taken from
the reaction's RMG family label map; explicit seed metadata (either C/N/O roles or the
'*1'/'*2'/'*3' labels) still wins when present. A validator rejects assignments that are
not three distinct in-range indices with C, N and O element symbols in their roles, in
which case the constraint derivation returns None and CREST skips the seed.

Also skip the three-center terminal-terminal distance in constraints.inp when it is
already an explicit distance pair, which is the case here (C-O is the forming bond).

(cherry picked from commit 6815bab)
(cherry picked from commit a703b52 on fix_job_log_quality)
(cherry picked from commit b6fd09c on fix_job_log_quality)
(cherry picked from commit dd7cf4a on fix_job_log_quality)
(cherry picked from commit fc6ef14 on fix_job_log_quality)
check_irc_species_and_rxn() pessimistically set ts_checks['IRC'] = False before
performing any comparison, and one path (the bond-list fallback failing to obtain
the reaction's connectivity) returned while leaving that False in place. A False
IRC verdict therefore conflated "the IRC endpoints were compared and do not match
the reactants/products" with "the IRC endpoints could not be compared at all",
which makes False unusable as a gate.

The check now starts from None (unknown, the ts_checks default) and only writes
False where a comparison was actually carried out and did not match: after a
failed isomorphism comparison, and after a failed bond-list comparison. The
no-comparison path leaves None and logs the reason, including the exception.
…hem silently

A TS whose IRC endpoints re-perceive as something other than the reaction's
reactants and products does not describe that reaction, yet ARC still fitted and
published an Arrhenius expression for it with nothing in the output indicating
that validation had failed (benchmark reaction nitroethane <=> ethyl nitrite,
family intra_NO2_ONO_conversion: ts_checks IRC False, k off by ~9 orders of
magnitude at 1000 K).

The rate is deliberately still computed and reported - it is diagnostically
valuable and it is how this class of bug is found - but every artifact carrying
it is now labeled, via the new common.get_ts_validation_comment() helper:

- Arkane parse_reaction_kinetics(): a 'ts_validation' key on reaction.kinetics
  and the marker appended to the Arrhenius comment.
- processor.compare_rates(): a 'ts_validation' field in output/RMG_kinetics.yml.
- plotter.save_kinetics_lib(): the marker as the Arrhenius comment and at the top
  of the entry's longDesc in the RMG kinetics library.
- plotter.draw_kinetics_plots(): the marker in the rate_plots.pdf plot title.

Each of these also logs an error naming the reaction. Only a ts_checks['IRC']
value of False (checked and failed) triggers the marker; None (not checked, e.g.
job_types irc: false) and True are left untouched.
…he wells

The IRC verdict was written into ``TS.ts_checks['IRC']`` but never read by any
production code path: ``check_ts()`` explicitly exempted 'IRC' from
``ts_passed_checks()``, and ``Scheduler.check_irc_species()`` only computed the
verdict and dropped it. A TS whose two IRC endpoints both optimize to the same
well was therefore accepted, and a rate coefficient that does not describe the
requested reaction was published.

A positively failed IRC check now rejects the TS and triggers a search for a
different TS guess, mirroring the existing treatment of a failed normal mode
displacement check. The check is three-valued: only ``False`` rejects. ``None``
means the check was not performed (e.g., IRC jobs were not requested) and never
rejects anything, in ``ts_passed_checks()`` as well.

Rejection reuses ``switch_ts()``, which resets the TS checks, the output paths
and job types, the rotors, and deletes the IRC species spawned from the rejected
guess, so the newly chosen guess re-runs opt/freq/sp/IRC from a clean state.
Termination is bounded by ``chosen_ts_list``: once every guess was tried,
``determine_most_likely_ts_conformer()`` sets ``ts_guesses_exhausted``, and the
TS is marked unconverged instead of being switched again.

Also guard the deletion of an empty ``running_jobs`` entry in the main loop,
since rejecting a TS while processing the opt job of one of its IRC species
removes that species' entry.

Cherry-picked into arcbench, where check_irc_species() already called the
logging-only Scheduler.report_irc_verdict(). Composed rather than duplicated:
report_irc_verdict() stays the single log site (it gained an optional ts_checks
override and now returns the verdict), and process_irc_verdict() delegates the
logging to it and performs only the enforcement.
GCN inference is stochastic and was never seeded, so the same reaction with
byte-identical inputs produced entirely different TS guesses between runs.

Add TS_SEARCH_RANDOM_SEED to settings (default 1, matching the existing RDKit
randomSeed=1 convention, overridable via the ~/.arc overlay) and hand it to the
GCN subprocess, which runs under the ts_gcn env where ARC's own process-level
seeding cannot reach: --seed in the direct/incore mode, the 'seed' key of
input.yml in the batch/queue mode. The script seeds python-random, NumPy and
PyTorch (CPU + CUDA) immediately before each inference, and ARC additionally
exports PYTHONHASHSEED into the child environment, since that one only takes
effect before the child interpreter starts.

Each repetition uses base seed + repetition index, so the repetitions still
sample different starting points while the whole set of guesses is reproducible.

CREST is not seeded: the installed CREST 2.12 exposes no seed flag at all
(its only random-related option, -samerand, applies to xtbiff, not to the
GFN2-xTB metadynamics), so its non-determinism cannot be controlled from ARC.
cluster_tsgs() kept the first-seen member of each near-duplicate cluster, so the
survivor depended on the order the guesses were appended. That order is not
stable: process_completed_tsg_queue_jobs() assigns an index at arrival time and
re-clusters immediately, so orca_neb/qst2 jobs completing in a different PBS
order swapped indices and could flip which member survived - and the survivor's
geometry is what gets DFT-optimized.

Traverse the guesses in ascending index order and keep the lowest-index member
as the cluster representative. Index rather than energy: at clustering time most
guesses still have energy is None, so an energy rule would be undefined and
itself order-dependent. Surviving guesses are not renumbered - their indices are
provenance - so the numbering may have gaps, which the clustering log message now
states along with which duplicates were absorbed into which survivor.

Two supporting fixes without which the lowest-index rule is not well defined:
the 'cluster' bookkeeping now accumulates across the repeated clustering passes
driven by arriving queue jobs instead of being reset to the survivor's own index
each pass, and new indices come from get_next_tsg_index() rather than
len(ts_guesses), which after a clustering pass could re-issue an index that a
survivor or an absorbed guess already held.
calvinp0 added 4 commits July 31, 2026 17:56
``TSGuess.index`` is a stable identity (what clustering orders by, what
``chosen_ts`` / ``chosen_ts_list`` refer to, and what keys the TS guess
report); a list position is not. Four sites treated the two as
interchangeable ints:

* ``ARCSpecies.process_xyz`` never set ``index`` on a user TS guess, so it
  stayed ``None`` -- an index-less guess can never be matched by the
  ``chosen_ts`` selection loop. Assign one, continuing past the highest
  index in use (clustering removes guesses but preserves the survivors'
  indices, so the list length can collide with an index already taken).
  The redundant positional re-subscript is dropped; ``TSGuess.__init__``
  already forces ``success`` for a user guess.
* ``Scheduler.troubleshoot_ess`` indexed ``ts_guesses`` with the number
  parsed out of a ``tsg<i>`` job name. That number identifies the
  TS-search *adapter* that was dispatched, not a guess: an adapter may
  contribute several guesses or none, so this annotated an unrelated
  guess, and raised ``IndexError`` -- aborting the whole run -- whenever
  the adapter number exceeded the number of guesses generated so far.
  Match on the adapter that produced each guess instead.
* ``Scheduler.run_ts_conformer_jobs`` reported ``ts_guesses[0]`` as the
  provenance of the lone successful guess while optimizing
  ``successful_tsgs[0]``'s coordinates. Several adapters append
  ``success=False`` guesses, so those are not the same object and
  ``chosen_ts_method`` could name a method that produced no geometry.
* ``output._get_ts_imag_freq`` subscripted ``ts_guesses`` with
  ``chosen_ts``, reporting a different guess's imaginary frequency
  whenever the two numberings diverged.

None of these change which TS guess is selected.

(cherry picked from commit bed4eac)
ARCSpecies.chosen_ts stores a TSGuess.index (an identity assigned when the
guess is appended to the species), while TSGuess.conformer_index is the
position of the guess among the *successful* guesses (the conformer
optimization job index). check_negative_freq() matched the chosen TS guess
by conformer_index, and additionally relied on the loop variable leaking out
of the for loop, so a no-match left 'tsg' bound to the last guess rather than
to None. Since check_imaginary_frequencies(None) returns True by design (a
guess whose freqs were not computed yet must not be disqualified during
pre-screening), a TS with zero, two or three imaginary frequencies could be
accepted as converged while the real frequencies were never recorded.

Match by TSGuess.index via a new get_chosen_tsg() helper, and treat a
no-match as an unverified frequency check: log a warning naming the label,
chosen_ts and the available indices, and look for a different TS conformer
instead of silently accepting the geometry.

Also drop the same identity clobber in the pipe ts_opt ingestion, which
overwrote TSGuess.index with the conformer index.

(cherry picked from commit 56d2c03)
_initialize_adapter() and JobAdapter's local path setup both did

    if not os.path.isdir(path):
        os.makedirs(path)

which is a check-then-act race: two adapters initializing against the same
project directory, or two processes sharing a project, can both pass the
isdir() check and the loser gets FileExistsError from makedirs().

Pass exist_ok=True so the create is idempotent. The isdir() guard is kept so
the common case still short-circuits.

(cherry picked from commit eeab94b)
pyproject.toml runs the suite with `-n auto --dist=worksteal` and CI uses
`-n 6`, but several adapter test modules build their scratch project directory
from a fixed path under arc/testing/. setUpClass runs once per worker that
receives any test of a class, and the teardowns rmtree that tree, so one worker
could delete input files out from under another worker's running test. The same
collision happens across modules that happened to pick the same directory name
(common_test.py and gaussian_test.py both used test_GaussianAdapter).

Derive each scratch directory from PYTEST_XDIST_WORKER (falling back to 'main'
when running serially) and give the colliding modules distinct base names.

Measured on this branch, running adapter_test + common_test + orca_test +
molpro_test + orca_neb_test three times each:

    before:  8 failed, 37 passed,  9 errors
             8 failed, 34 passed, 12 errors   <- three tests lost to the race
             8 failed, 37 passed,  9 errors
    after:   8 failed, 37 passed,  9 errors   (identical across all three runs)

The residual failures/errors are an unrelated local ess_settings gap ('server1'
is not configured on this machine) and are unchanged by this commit; the point
is that the results stop varying between runs.

(cherry picked from commit 93f281f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants