Test whether the SCF reference is the ground state, and act on the answer - #1014
Test whether the SCF reference is the ground state, and act on the answer#1014calvinp0 wants to merge 11 commits into
Conversation
32f6064 to
ff61563
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1014 +/- ##
==========================================
+ Coverage 64.60% 65.02% +0.41%
==========================================
Files 119 120 +1
Lines 39785 40462 +677
Branches 10307 10473 +166
==========================================
+ Hits 25703 26310 +607
- Misses 11105 11124 +19
- Partials 2977 3028 +51
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:
|
3eb7956 to
1c9636b
Compare
|
Reviewed at First, the honest framing: this is careful work. The instrumentation is good, the adoption contract is stated precisely enough to be tested against, the fixtures are real ESS output rather than hand-made, and the PR description is unusually self-critical. Most of what follows is downstream of a single line that predates this branch, and which this PR's own docstring already explains. I've measured that one, so it isn't a hypothesis. How to read the confidence numbers — they record how a claim was established, not how strongly it reads:
Fix first1. The adopted unrestricted reference collapses back to restricted — the feature is currently a no-op
Composed inputs, with an adopted verdict on a singlet TS: The line is pre-existing at the merge-base — this PR doesn't introduce it, it collides with it: And the collision is guaranteed on exactly the path that needs the other branch: You already state the mechanism, at
That reasoning is right, and it's applied to protect the measurement. It isn't applied to the jobs the verdict is adopted for. The measurementPySCF 2.14, B3LYP/def2-TZVP, grids level 4, Three things this establishes:
The internal/external split agrees across both programs: PySCF's Consequence. On this fixture, the adoption path produces −196.4901 Ha and records Caveat on provenance, stated plainly: this was measured in PySCF, not Gaussian. The mechanism is program-independent — the RKS solution is a stationary point of the UKS equations and α=β is preserved by the SCF — and the reference energy cross-validates against your own fixture. But PySCF's Reproduce itimport numpy as np
from pyscf import dft, gto
GEOM = """
C 0.42653500 1.47789200 -0.00007700
C 0.49427800 0.01191500 -0.00000300
C 1.79225900 -0.71265700 0.00003600
C -0.76952500 -0.70318200 0.00003500
C -2.09552400 -0.11756100 0.00001100
H 1.39854400 1.97008900 -0.00010900
H -0.16267200 1.83051900 -0.86690400
H 2.40600800 -0.45644800 -0.87511100
H 2.40600000 -0.45636800 0.87516500
H 1.65286800 -1.79552300 0.00008500
H -0.70100100 -1.41388300 0.84851300
H -0.70100600 -1.41396700 -0.84837200
H -2.27706200 0.94604900 -0.00003500
H -0.16265800 1.83060800 0.86672200
H -2.94716600 -0.77952000 0.00003900
""" # arc/testing/stability/rhf_uhf_instability_singlet_ts.out, first Input orientation block
mol = gto.M(atom=GEOM, basis='def2-tzvp', charge=0, spin=0, verbose=0, max_memory=16000)
mf_r = dft.RKS(mol); mf_r.xc = 'b3lyp'; mf_r.grids.level = 4
mf_r.conv_tol = 1e-9; mf_r.max_cycle = 200
e_r = mf_r.kernel()
def make_uks():
mf = dft.UKS(mol); mf.xc = 'b3lyp'; mf.grids.level = 4
mf.conv_tol = 1e-9; mf.max_cycle = 200
return mf
# guess=read analogue: seed UKS with the converged restricted density
dm_r = mf_r.make_rdm1()
mf_u1 = make_uks()
e_u1 = mf_u1.kernel(dm0=np.array([dm_r / 2, dm_r / 2]))
# guess=mix analogue: rotate HOMO/LUMO oppositely in the two spin channels
nocc = mol.nelectron // 2
h, l = nocc - 1, nocc
c = 1.0 / np.sqrt(2.0)
homo, lumo = mf_r.mo_coeff[:, h].copy(), mf_r.mo_coeff[:, l].copy()
mo_a, mo_b = mf_r.mo_coeff.copy(), mf_r.mo_coeff.copy()
mo_a[:, h], mo_a[:, l] = c * (homo + lumo), c * (homo - lumo)
mo_b[:, h], mo_b[:, l] = c * (homo - lumo), c * (homo + lumo)
occ = np.zeros(mf_r.mo_coeff.shape[1]); occ[:nocc] = 1.0
mf_u2 = make_uks()
e_u2 = mf_u2.kernel(dm0=mf_u2.make_rdm1((mo_a, mo_b), (occ, occ)))
print(e_r, e_u1, mf_u1.spin_square()[0], e_u2, mf_u2.spin_square()[0])Fix directionOn adoption emit Worth noting this is not reliably a uniform no-op: an unstable RKS solution is a saddle in orbital space, so DFT grid noise can seed the collapse in some species and not others. One campaign can yield a mix of restricted and broken-symmetry TSs, all labelled unrestricted. And it is invisible to the instrument this PR ships — 2. Five of the new tests are order-dependent and will flake CI red
logging.addLevelName(logging.WARNING, 'Warning: ') # process-global, permanentThe five new tests assert
This is also why the "zero failures" claim doesn't hold: full tree, single process, clean HEAD is Fix: assert 3. Adoption is inert for the species it measures
if not switch_ts and species_has_sp(...):
self.check_rxn_e0_by_spc(label) # 3182
if not switch_ts and (self.species_dict[label].is_ts
or job_scf_reference_is_restricted(job) is True):
self.run_stability_job(label=label, freq_job=job) # 3186The barrier is computed and E0-checked before the stability job is spawned. The verdict lands asynchronously, and Two independent passes reached this from opposite ends: the ordering above, and the observation that a TS guess's sp is typically already composed by the time its verdict lands. Worth deciding explicitly whether adoption is meant to be retroactive. If yes, it needs a re-run path. If no, the description's "adopted for subsequent jobs" should name which subsequent jobs actually exist. Then4. A DFT-level verdict flips the reference at every other level and ESS
The stability job runs at Neither Orca nor Molpro has symmetry-breaking machinery in ARC's templates (Orca needs 5. The mixed-reference check is structurally blind in the most common configuration
When Fix: record the reference in 6.
|
| Item | Location | Confidence |
|---|---|---|
| The six new fixtures carry the submitting account, hostname, compute-node name and scratch-path layout in their headers and archive blocks | arc/testing/{stability,spin}/*.out |
10 |
Unguarded int() on a log-derived digit run; qchem.py already guards the same thing |
gaussian.py:361, orca.py:226 |
10 |
parse_wavefunction_stability has no base-class default on ESSAdapter, unlike parse_s_squared/parse_ess_version; safe only via the getattr in make_parser |
arc/parser/adapter.py |
9 |
Returns: <type> inline instead of Google style |
spin.py:23,90,143; parser.py:294; job/adapters/common.py:363; output.py:267,296 |
9 |
No Returns: section at all |
run_stability_job, check_stability_job, log_open_shell_character_sources |
9 |
Two import tempfile inside test bodies |
parser_test.py:1258,1475 |
10 |
parse_s_squared implemented three times with a common skeleton |
gaussian/orca/qchem | 8 |
On the fixtures: this is the repo's existing norm rather than anything new — 13 tracked files at the merge-base carry the same host identifier and 6 the same account, and /gtmp is hardcoded in arc/settings/settings.py. No keys, tokens, emails or licence numbers are present. Scrubbing the six new ones is cheap and none of the new parsers key off those substrings; whether to backfill the pre-existing files is a separate question.
Checked and sound — please don't spend time re-verifying these
Chemistry
- The Yamaguchi formula is correct. Denominator is ⟨S²⟩_HS − ⟨S²⟩_BS, derived from E_BS = (1−w)E_LS + wE_HS with w = (S²_BS−S²_LS)/(S²_HS−S²_LS). Verified algebraically and numerically. The
s2_lsplumbing is complete — it reaches the formula, the ordering guard (:107) andbroken_symmetry(:150). - Before-annihilation ⟨S²⟩ is right and consistently fed.
s_squared: 0.7536from the<Sx>=line vss_squared_annihilated: 0.75. TheInitial guessexclusion works (uhf_died_before_scf_septet.out→None, not 12.0) and the first-Multiplicityrule survivesguess=fragment(→ expected 0.75, not the fragment's 1). - AEC/BAC is not a new problem.
arc/statmech/arkane.py:373-398applies corrections uniformly; a TS's energy enters only via E0(TS) − E0(reactants), where atom counts are identical and AEC cancels exactly. Wells are never adopted. Your stated reason for excluding wells is sound, and the AEC half doesn't additionally condemn the TS case. - The composite/semiempirical early return should be closed, not carried as a follow-up.
uCBS-QB3isn't a Gaussian keyword —CBS-QB3selects its open-shell variant from the multiplicity itself, as do AM1/PM6 — andrun_stability_job:1802-1806refuses any non-DFT/HF level, so a derived verdict can't exist for a composite species anyway. - Your
guess=mixre-derivation holds.arc/testing/restart/1_restart_thermo/calcs/freq_a19031.out: route line 84#P guess=mix wb97xd/def2tzvp freq, line 105Multiplicity = 1, line 223Mixing orbitals, IMix= 1 ... Coef= 7.07106781D-01 7.07106781D-01, line 422SCF Done: E(RwB97XD). g16 does apply mixing on a restricted reference. Note this is also the fact finding 1 turns on. - The fixtures are genuine — g16 RevC.02, real
l1.exepaths, real archive blocks, real timestamps.
Persistence
- The provenance does survive a restart — via
restart.yml/ARCSpecies.as_dict()(species.py:810-813), read back at:947-949. Not viaoutput.yml, which is confirmed write-only/export. The claim holds; the description's stated mechanism is the part to correct. - Old pre-PR restart fixtures resume cleanly. All 4 species of
1_restart_thermo/restart.ymlconstructed; the three new attributes defaulted toNone/{}/None. NoKeyError/TypeError/AttributeError. - No stability respawn on resumed old projects —
default_job_types['stability']isFalseandinitialize_job_typesfills the missing key fromdefaults_to_false. - The
number_of_radicalsprovenance question is moot. Only two assignments exist inarc/: the constructor default (species.py:382) and the restart read (:946). ARC never writes it programmatically, so a restored value is exactly as user-declared as a fresh one. - No tuple→list degradation. In real usage
relaxations/negative_eigenvectorsare always lists. ts_checkssemantics untouched by this diff.restricted_usedround-trips safely — popped inrestore_running_jobsbeforejob_factory.
Contracts
ESSAdapter.parse_s_squaredhas a safereturn Nonedefault (arc/parser/adapter.py:210), not@abstractmethod. All 9 subclasses probed; the 6 without an override inherit cleanly, noAttributeError.- No
JobAdapterabstract method added, so no adapter is forced to implement anything new.job_type='stability'is only ever constructed byrun_stability_job, which gates onjob_adapter != 'gaussian'. - The
default_job_typesin-place-mutation trap does not bite —arc/common.py:92listsstabilityindefaults_to_false; zero import-timedefault_job_types[key]subscripts anywhere. specific_job_type: stabilityruns nothing, confirmed by execution, matching your own admission — and it does not also hang, sincecheck_all_done(:3843-3845) skipsstability. It's the only such trap this PR introduces.check_negative_freq's return-shape change is fully absorbed — both call sites unpack, all 9 returns in the body are 2-tuples.
Security / performance
- No ReDoS in any of the nine new regexes at n = 200 / 2,000 / 20,000 / 200,000 — nothing over 50 ms. Both parsers gate on cheap substring checks before invoking
re.search. - No
eval/exec/pickle/yaml.load/subprocess/shell=Truein the new source. - No log-derived string reaches an input deck. The stability route section (
gaussian.py:333-336) is composed exclusively of module constants;run_stability_jobpasses onlyxyzandlevel. - No path traversal — the only path written to
output.ymlis ARC-constructed, never parsed out of a log. - Checkfile warm-start confirmed — each stability job is an SCF restart plus a matrix diagonalization, not a re-optimization. (Ironically, the same mechanism as finding 1.)
- No completed job is invalidated or re-queued on adoption.
- No per-tick re-parse —
check_stability_jobis called once, from the single job-completion branch at:869. - Parser cost at scale: a synthetic 210 MB log gives 1.73 s / 392 MB peak for
parse_wavefunction_stabilityand 1.51 s / 394 MB forparse_s_squared— the samereadlines()pattern every existing ARC parser uses, and real logs are orders smaller. - Parser false positives swept: across all tracked
.out/.logfixtures, only the four new stability fixtures parsed —parsed_count 4, errors_count 0. stabilitycannot block convergence and is correctly reset bydelete_all_species_jobs.
Mutants the suite killed — i.e. the tests work:
'unknown'→'stable'on an unreadable verdict: killed bytest_unparsed_verdict_is_not_reported_as_stable.number_of_radicals is None→ truthiness: killed, confirming declared-0is distinguished from undeclared-None.number_of_radicals > 1→>= 1: killed, confirming a declared1isn't credited as an open-shell source.- Q-Chem
len(tokens) >= 2→> 2: killed bytest_parse_s_squared_qchem.
Where this leaves the PR
Two chains account for nearly everything above.
Finding 1 is the review. guess=read from a restricted checkpoint returns E(RKS) exactly, so the adoption path changes no number today. Findings 3, 10 and 11 are downstream detail of the same defect: the feature measures correctly, records correctly, and then applies a reference flip that the SCF quietly undoes. Fix finding 1 and the PR does what its description says.
arc/checks/spin.py has no consumers, which is why findings 8 and 9 are latent rather than live. Every trap in it is harmless today and becomes a wrong published number the day someone wires it up. Fixing the API now costs a fraction of debugging it later. The same holds for finding 6 — invalidates_analytic_freq is computed and discarded.
A smaller third chain: findings 5, 6, 7 and 11 are all the same shape — a diagnostic that is computed, recorded, and never gated on. Acting narrowly by design is defensible, but four separate measurements currently reach no decision, and finding 7 is the one most likely to matter in a real campaign.
Method, for what it's worth
Eight parallel review passes over the same pinned diff — testing/mutation, security, API-contract, maintainability, performance, data-migration, an adversarial pass, and a cross-model adversarial pass — none of which saw each other's conclusions, plus the PySCF measurement above. Findings were deduplicated by file:line and by mechanism; cross-model findings were re-derived against the source before being accepted.
Three conclusions were reached independently by two passes each, which is why I'd weight them: adoption being inert for the measured species (finding 3), the provenance riding restart.yml rather than output.yml, and the TS-switch record losing its log path (finding 11). The restart spawn guard is recorded as unresolved precisely because two passes read the same lines and disagreed.
All test gates ran in arc_env with an empty HOME and pytest -n0. Known-failing at any SHA on my box, not attributable to this branch: checks/ts_test.py::test_check_rxn_e0, ::test_compute_rxn_e0, processor_test.py::test_compare_rates, statmech/arkane_test.py::test_run_statmech_using_molecular_properties (missing arkane in rmg_env), plus molecule_test.py::TestConnectTheDots.
No ESS job was submitted at any point.
33d20e6 to
f9c0a3a
Compare
…t of ESS logs Two ESS-log readers that nothing in ARC could previously perform, plus the real logs that pin them. Neither is wired to a caller here; later commits consume them. THE WAVEFUNCTION STABILITY VERDICT. Gaussian's documentation states that "analytic frequency calculations are only valid if the wavefunction has no internal instabilities". ARC runs a freq job on every TS, so that precondition was assumed and never tested, and for a restricted singlet TS it cannot even be inferred after the fact, because a restricted wavefunction prints no <S**2>. parse_wavefunction_stability reads a stable=(rext,noopt) log and returns the verdict, the label and eigenvalue of each negative stability-matrix eigenvalue, and whether the analytic frequencies are invalidated. Whether an instability bears on the frequencies depends on the reference: Gaussian's rule is that a restricted wavefunction need only be free of singlet, i.e. internal, instabilities, while for an unrestricted one any instability invalidates the analytic frequencies. The reference is read from the log's own SCF Done: E(RwB97XD) / E(UwB97XD) line rather than predicted from the species, so the verdict and its consequence are derived once, here, from what Gaussian actually did; the scheduler line and output.yml both read that single result instead of recomputing it. A log that ran an analysis but whose verdict could not be read is reported as 'unknown', never as 'stable', so a gap in the recognised phrasings cannot pass for a clean bill of health, and invalidates_analytic_freq is left undecided rather than guessed when the reference cannot be read. A log with no stability analysis at all returns None. A STABLE UNRESTRICTED GAUSSIAN VERDICT REPORTS external_instability AS None RATHER THAN False, WHICH CHANGES WHAT THE GAUSSIAN READER RETURNS for that case. All four Gaussian fixtures, both UB3LYP ones included, hold exactly one <AA,BB:AA,BB> singles matrix and no <AB,BA spin-flip block, so for an unrestricted reference Gaussian never computes a spin-flip root and the external sector is not tested at all. Reporting False there asserts a test that was not run, and it disagreed with the ORCA reader, which reports None for the identical physical situation. A restricted reference is unaffected: its single matrix spans both sectors, so a stable verdict there still reports both flags False. invalidates_analytic_freq is unchanged in every case, and the cross-ESS test now asserts both flags rather than the verdict and reference alone. The two log-derived digit runs the readers feed to int() are guarded with arc.common.is_str_int, as the Q-Chem reader already guards its own: the regexes rule out a non-numeric match, but CPython's int_max_str_digits makes int() on a run of more than 4300 digits a ValueError, and a truncated or corrupt log can hold one. The six Gaussian fixtures' Link 0 line carried the submitting account, the compute node and the scratch layout of the machine they were run on. All three are replaced with a neutral scratch path; every parser this branch adds returns byte-identical results before and after. Four real Gaussian stable=(rext,noopt) logs of campaign TS geometries are added under arc/testing/stability/: a restricted singlet with an RHF -> UHF instability, a stable restricted singlet, a stable unrestricted doublet, and a stable spin-contaminated doublet. They establish two things the parser had assumed otherwise. A RExt run emits one analysis with one verdict line, not an internal and an external one. And the eigenvector symmetry label follows the reference: restricted logs label roots by spin (Triplet-A), unrestricted logs label them by the root's own spin expectation value (2.012-A). Reading the reference off the SCF Done line is confirmed on all four. THE S**2 SPIN-CONTAMINATION DIAGNOSTIC, RE-HOMED FROM AN ARCBENCH-BASED BRANCH. parse_s_squared, s_squared_expected_from_multiplicity, the ESSAdapter default and the Gaussian / ORCA / Q-Chem implementations come from feature_s_squared_spin_ diagnostic, with the Gaussian anchoring fix of fix_s_squared_stability_eigenvector folded in rather than applied afterwards, so the pre-fix parser is never present in this history. Left behind deliberately: that branch's arc/tckdb/ payload builder and its attachment to the sp calc, because arc/tckdb/ does not exist on main and bringing the builder would have meant inventing the module around it. The output.yml key it reads, sp_spin_diagnostic, is brought in the same shape and key names, so the arcbench-side emitter binds to it unchanged. WHY parse_s_squared SURVIVED OVER parse_spin_squared. A second, independently written Gaussian reader returning a bare float existed on a third branch. The two were compared line by line before discarding one. They agree on everything that could have made this a semantic merge rather than a deletion: both anchor on a line carrying <Sx>= and exclude the Initial guess line, so neither reads a Stable job's Eigenvector root spin as the reference's; both take the last such line, so both report the converged SCF rather than an earlier cycle; both take the value BEFORE annihilation of the first spin contaminant; and both return None for a restricted reference, which prints no spin line at all. Two differences were adjudicated: Return shape. The dict is kept. A bare float discards the annihilated value and the ideal S(S+1), both of which output.yml records, and cannot express "read the reference, but the log states no multiplicity". Numeric spelling. Fixed-point is kept over accepting a Fortran D exponent: Gaussian's spin line and its "S**2 before annihilation" line are both fixed-format F fields, and the D tolerance was speculative rather than fixture-driven. Keeping the deployed regex also keeps this file from diverging from the copy running the benchmark. The choice is stated in parse_s_squared's docstring so it is not silently re-litigated. Gaussian multiplicity anchoring takes the FIRST 'Charge = C Multiplicity = M' line: it is the symbolic Z-matrix echo of the job's own molecule specification, while any later one declares the multiplicity of a single fragment of a guess=fragment calculation, which is not the wavefunction's. arc/testing/spin/uhf_fragment_guess_doublet.out pins it. The initial-guess exclusion is pinned by a fixture that can only be read correctly with the guard in place: arc/testing/spin/uhf_died_before_scf_septet.out is a real septet that printed 'Initial guess ... <S**2>=12.0000' and then died in l502 on an inaccurate quadrature before any SCF Done. Without the guard the parser reports 12.0000, exactly S(S+1) for a septet, as a converged diagnostic of a wavefunction that never existed. With it the file yields None. Extracted, not fabricated. ORCA needs no equivalent anchoring: across all six ORCA fixtures the string 'Expectation value of <S**2>' occurs only inside the UHF SPIN CONTAMINATION block following a converged SCF, and MDCI's '<S**2>(linearized)' does not contain it. 'Last wins' is correct there and is now pinned. What was fixed is that 'Expectation value' and 'Ideal value' were latched independently, so a block missing its Ideal line would have inherited an earlier block's; the expected value is now reset when a new expectation value is read, tying the pair to one block. The vestigial `and 'Mult' in line` is removed: 'Mult' is a substring of 'Multiplicity', so the condition was unconditionally true. Cross-ESS multiplicity rules are assessed and left alone. Gaussian and Q-Chem take the first declaration, ORCA the last; each is right for its own format's dominant multi-declaration case, and all three are fallbacks only. The restricted contract is assessed and left alone. An RHF/RKS determinant is an exact eigenfunction of S**2 with <S**2> = S(S+1) exactly, and returning None for it conflates that with a parse failure. Returning the exact value would need a new reference-detection sniffer in each of the three adapters purely to undo what the parser just did, and would split the base class's contract under which None means 'no diagnostic available' for Molpro and CFOUR alike. ESSAdapter.parse_s_squared's docstring now states the conflation so a consumer knows to compute the restricted value from the species' multiplicity instead of reading a log. Deferred cleanup: s_squared_expected_from_multiplicity is homed in arc/parser/parser.py but does no parsing; it is pure spin arithmetic and belongs in arc/checks/spin.py. It duplicates nothing (checked). Moving it would touch three more adapters, so it is recorded here rather than done. Verified by mutation, not only by a green run. Making the Gaussian S**2 reader return None unconditionally fails 13 tests; restoring the pre-fix anchoring, which reads the last <S**2>= line whatever it sits on, fails 5, including the restricted Stable log that then reports a fabricated diagnostic where its contract is None; making the ORCA and Q-Chem readers return None fails 4 and 1; taking the last Gaussian multiplicity fails 1; dropping the initial-guess guard fails 1; deleting the ESS s_squared_expected fallback fails 1. ORCA reads the same verdict out of its own logs. OrcaParser gains parse_wavefunction_stability, returning the schema GaussianParser returns plus two keys for behaviour Gaussian has no equivalent of, and ESSAdapter gains a base declaration returning None so the parser can be dispatched to any ESS, as parse_s_squared already could. THE FIRST ANALYSIS IS THE ONE UNDER TEST. ORCA 6.0.0 aborts in LEANSCF on an unstable wavefunction unless it is told to follow the instability, so ARC always sets STABRestartUHFifUnstable true and an unstable log therefore holds TWO analyses with opposite verdicts: the wavefunction the frequency job built its Hessian from, then the solution ORCA relaxed into. The verdict, the lowest eigenvalue and the negative roots are read from the first; n_analyses and followed_to_stable report the second without overwriting it. The reference is read from the HFTyp line preceding the first analysis, so a restart to an unrestricted solution does not rewrite the reference tested. BOTH CODES TEST THE SAME SPACE, and the flags follow from that. ORCA analyses RHF/RKS in UHF/UKS space and UHF/UKS in UHF/UKS space, both Ms-conserving; all four Gaussian fixtures print 'Stability analysis using <AA,BB:AA,BB> singles matrix', which is the same Ms-conserving block, and Gaussian's Ms-changing <AB,BA:AB,BA> block appears in none of them. Neither code reaches the GHF sector, so neither verdict is the weaker one. The verdicts agreed on all four measured systems, and at matched functional (ORCA's B3LYP/G is Gaussian's VWN3; plain ORCA B3LYP uses VWN-5) the lowest roots agree to under 0.4% on the three systems where both codes found the same SCF solution. The fourth, a near-dissociated O(3P)...CH3 pair at r(O-C) = 3.78 A, has the two codes converged to DIFFERENT UHF solutions (total energies 0.025 Eh apart, <S**2> 1.7488 against 1.700055), so its roots compare two wavefunctions rather than two codes and support no cross-code conclusion. An earlier revision of this branch claimed the opposite and is corrected here, in the parser docstring and in the documentation. For an unrestricted reference an instability is spin-conserving, i.e. Gaussian's internal sector, and external_instability stays None since no spin-flip root is computed. For a restricted reference ORCA prints one unlabelled matrix spanning both sectors, so the sector is MEASURED rather than assumed: ARC is forced to run STABRestartUHFifUnstable true, so the log already carries the spin expectation value of the solution ORCA relaxed into. A nominal singlet reaching a stable solution at <S**2> above a small threshold broke the spin symmetry, which is external; one reaching a stable solution still at <S**2> of zero moved within the spin-conserving sector, which is internal. A restart that never reached a stable solution measures nothing, and the verdict is then 'unattributed_instability' with both flags None -- never 'stable', and never grounds for changing a reference. Assuming external, as the first revision did, made derived_reference_is_unrestricted fire on an unmeasured guess AND suppressed the analytic-frequency-invalidity warning that a genuine internal instability must raise. s_squared_after_follow reports the measurement, and the restart fixture's test asserts it. invalidates_analytic_freq now applies the same rule the Gaussian reader applies, so one physical situation gets one answer whichever ESS measured it. Two smaller reader fixes. An RO reference is reported as restricted None rather than as restricted True, and gets no 'RHF -> UHF' relaxation: run_stability_job admits 'rohf', and neither flag names a constraint an ROHF instability relaxes. And a root printed as -0.00000000 parses to negative zero, for which '< 0' is False, so an unstable verdict came back with an empty root list and a blank log detail; negative zero now counts as negative. The five fixtures are real ORCA 6.0.0 B3LYP/def2-TZVP logs of the same four geometries the Gaussian fixtures were taken on, plus the log of a job run with the restart key false, which crashes after printing its verdict and is kept to pin that the verdict is still readable out of an errored log. The test that asserted orca['lowest_eigenvalue'] > 100 * gaussian['lowest_eigenvalue'] is gone. It froze an artifact of two codes converging to different UHF solutions under a name asserting a mechanism that does not exist, and would have passed forever. What replaced it asserts what the fixtures support: the two codes agree on every verdict and on every reference, and they agree on invalidates_analytic_freq. The errored-log test keeps its assertions and loses its claim: the parser does read a crashed log, but check_stability_job returns early on a non-done status and a LEANSCF crash classifies as errored/['Unknown'], so ARC never surfaces that verdict -- it is parser robustness, not a product guarantee.
E_LS = E_BS + [(<S**2>_BS - <S**2>_LS) / (<S**2>_HS - <S**2>_BS)] * (E_BS - E_HS) K. Yamaguchi, F. Jensen, A. Dorigo, K. N. Houk, Chem. Phys. Lett. 1988, 149, 537; applied to broken-symmetry DFT by T. Soda et al., Chem. Phys. Lett. 2000, 319, 223. Nothing reads it. No job is spawned, no verdict is consumed, no dispatch path is touched, and what Arkane receives is unchanged. The design it was first written for -- gating on a stability verdict, recomputing the saddle with a broken-symmetry reference and recording a projected energy -- was withdrawn by the chemistry review of its motivating dataset and is not revived. The arithmetic is here because it is self-contained and exactly derived, and because it is what says what an adopted unrestricted energy still is not: a broken-symmetry determinant is not a spin eigenfunction, it mixes in the higher multiplicity, so its energy lies ABOVE the spin-pure low-spin energy, while the restricted energy it replaces lies above the broken-symmetry one in turn. The ordering is E_projected < E_BS < E_restricted, so an adoption is a step toward the spin-pure energy that stops short of it, and ARC projects nothing. The module is registered in arc/checks/__init__.py, which listed common, nmd and ts and omitted spin, so arc.checks.spin raised AttributeError under the package's own access pattern. The source it was carried from was labelled WIP, and the label described the module as well as the withheld design. Four defects a quantum-chemistry review and an adversarial code review found independently are repaired here rather than carried on faith. GENERALISED BEYOND A SINGLET TARGET. The closed form that was implemented, (<S**2>_HS * E_BS - <S**2>_BS * E_HS) / (<S**2>_HS - <S**2>_BS), is the general expression with <S**2>_LS = 0 substituted in: it is singlet-only, and nothing in its name, signature, docstring or tests said so. A doublet TS, which is routine in ARC, was silently projected as if its spin-pure <S**2> were 0 rather than 0.75. For a broken-symmetry doublet / high-spin quartet pair with <S**2>_BS = 1.0, <S**2>_HS = 3.80 and a 0.1 Hartree gap the error is 0.0268 Hartree, 16.8 kcal/mol. The general form is implemented instead, and the target state is a REQUIRED argument with no default: both entry points take the multiplicity of the state being projected onto and derive <S**2>_LS from it through parser.s_squared_expected_from_multiplicity, which is the arithmetic ARC already had for exactly this value. A default of 0.0 would be the same singlet-only assumption made once more, silently, at every call site that omitted it, and the size of that mistake is the 16.8 kcal/mol above. A multiplicity that names no spin state -- missing, non-numeric, below one, non-finite -- is refused with a warning rather than treated as any particular one. BROKEN_SYMMETRY_S2_THRESHOLD had the same defect and is fixed the same way. It compared the ABSOLUTE <S**2>_BS against 1e-2, not its deviation from S(S+1), so for any non-singlet broken-symmetry reference the flag was unconditionally True and therefore carried no information -- a clean doublet at <S**2> = 0.7536 read as 'symmetry broken'. The comparison is now against s2_bs - s2_ls, which the signature can express only because s2_ls is now an argument. THE SEPARATION GUARD IS A PHYSICAL FLOOR, NOT A DIVISION-BY-ZERO GUARD. MIN_S2_SEPARATION was 1e-3, which prevents a ZeroDivisionError and nothing else. Two ordinary UHF doublets at <S**2> 0.7540 and 0.7560, a pair that should never have been projected at all, returned -209.803 Hartree from energies of about -195.29, i.e. 14.5 Hartree and some 9,100 kcal/mol below its own E_BS, reported as a number rather than as None. A genuine broken-symmetry singlet / high-spin triplet pair is separated by about 1.0 and a doublet / quartet pair by about 3.0, so the floor is raised to 0.1: below that the two references do not describe two distinguishable spin states. At a floor of 0.1 no division-by-zero guard is needed on top. THE AMPLIFICATION IS BOUNDED AS WELL, so the floor is not a cliff. A separation floor alone says nothing about the result: at a separation a hair above it the ratio multiplying (E_BS - E_HS) is unbounded, so the first pair admitted past the guard can return an arbitrarily large correction, while the pair a hair below it is refused on logger.debug, i.e. silently. Both are addressed. Every refusal is a logger.warning, because a numeric routine that declines to answer has to say so. And the quantity that decides how far the projection moves the energy, (<S**2>_BS - <S**2>_LS) / separation, is capped directly at MAX_PROJECTION_AMPLIFICATION = 2.0. That ratio is w / (1 - w) in the high-spin weight w of the BS determinant: an ideal, fully spin-flipped broken-symmetry solution has w = 0.5 and a ratio of exactly 1, so a ratio above 1 means the BS determinant carries more high-spin than target-spin character. The cap admits w up to two thirds and refuses beyond it, where the correction exceeds twice the BS-to-HS gap. The largest correction any accepted projection can apply is therefore 2.0 * |E_BS - E_HS|, and the first pair accepted past the separation floor is bounded by the same amount as every other. THE INVERTED CASE IS A DIFFERENT FAILURE and no longer shares a branch with the near-degenerate one. For a variationally converged UHF/UKS determinant <S**2> >= S_z(S_z + 1) always -- spin contamination only ever adds -- so for a properly matched pair at the same geometry and level <S**2>_HS > <S**2>_BS is guaranteed, not merely typical. An inversion therefore does not mean 'too close to project'; it means the two references are not the same calculation: the HS SCF converged to a different state, the geometries or levels differ, the arguments were transposed, or an SCF did not converge. It has its own branch and a logger.warning naming the inversion, worded distinctly from the benign near-degenerate one, instead of the arithmetically true but diagnostically misleading 'separated by -1.0, below the 0.001 required'. The mirror-image inconsistency, a broken-symmetry <S**2> below the target state's own S(S+1), is refused and warned about on the same grounds. NaN AND INFINITY BYPASSED EVERY GUARD, since NaN < 0.1 is False. s2_bs = NaN returned NaN, and get_spin_projection reported broken_symmetry = False for it, which reads as an affirmative 'this reference did not break symmetry' when the truth is that <S**2> is unknown. All five inputs are now validated with math.isfinite, and the three <S**2> arguments additionally for non-negativity, which no expectation value of S**2 can violate. broken_symmetry is None, never False, whenever it cannot be judged, as the docstring already promised for the projected energy. The docstring said 'interpolating in <S**2> between the BS reference and the high-spin reference'. It is extrapolation: the low-spin target lies outside the interval the two references bracket, always, which is the whole point of the scheme. The stated reason for refusing an inverted pair, that it 'places the low-spin state outside the interval the two references bracket', was wrong for the same reason. Both are corrected. The tests are rewritten against literal numbers. The originals referenced MIN_S2_SEPARATION and BROKEN_SYMMETRY_S2_THRESHOLD symbolically -- for instance s2_hs = 2.0 + MIN_S2_SEPARATION / 2 -- which is tautological: it holds for any value of the constant, so mutating 1e-3 to 1e-12 and 1e-2 to 1e3 left all 12 tests green. The constants are now exercised through literal values that fail if any moves, and all three are additionally asserted directly so that changing one is a deliberate act. Each comparison boundary is pinned at the boundary itself: a separation of exactly MIN_S2_SEPARATION is projected and anything below it is not, an amplification of exactly MAX_PROJECTION_AMPLIFICATION is projected and anything above it is not, and a deviation of exactly BROKEN_SYMMETRY_S2_THRESHOLD is not reported as symmetry broken. 36 tests, from 12. Verified by mutation, not only by a green run. Returning None unconditionally fails 13 tests; inverting the sign of the projection term fails 8; replacing the separation guard with a never-taken branch fails 3; reverting the non-finite validation to the original `any(value is None ...)` fails 3; MIN_S2_SEPARATION 0.1 -> 1e-12 fails 4; and BROKEN_SYMMETRY_S2_THRESHOLD 1e-2 -> 1e3 fails 3. Each of the last two survived every one of the original 12 tests. The three boundary comparisons were mutated one at a time: the separation guard's < to <= fails 2, the symmetry-breaking > to >= fails 1, and the amplification cap's > to >= fails 1. THE RECORD NAMES WHAT PRODUCED THE ENERGIES. get_spin_projection takes the level of theory and the geometry as required arguments and carries both, along with the target multiplicity and the <S**2>_LS derived from it. The scheme extrapolates between two points of ONE potential energy surface, so a pair taken at two levels, or each from its own state's optimized geometry -- which ARC has lying around for the high-spin state -- is not a pair the projection is defined for. Nothing in the record said which surface its two energies came from; now it does.
Registers a new job type, off by default, that submits one Gaussian stability analysis at the freq level of theory and on the freq geometry. PLACEMENT. Stable is itself a Gaussian job type keyword, and Gaussian documents that only one job type keyword should be specified, the exceptions being Opt Freq and Polar Freq. So the keyword cannot be appended to the TS freq route. It could syntactically be appended to ARC's sp route, which carries no job type keyword, but ARC's sp is typically a composite or wavefunction-method energy, where stability analysis is unavailable (it is documented for HF and DFT only) and where a second job type keyword would displace the energy of record. A separate job at the freq level tests the wavefunction the Hessian is built from, and follows the shape of ARC's existing 'orbitals' job: a diagnostic that nothing depends on. KEYWORD. The route emits stable=(rext,noopt). NoOpt is Gaussian's default and is stated explicitly: it reports an instability without reoptimizing the wavefunction into the lower solution, so no rotated orbitals are produced. ARC also never propagates this job's checkfile -- species.checkfile is only repointed from opt, optfreq and composite jobs, and from troubleshooting, which this job is exempt from. Stable=Opt, RepOpt and 1Opt are never emitted, and complex-orbital testing is not enabled. RExt is kept over Int: it costs nothing extra, and Int would discard the broken-symmetry information the diagnostic exists to count. The job is skipped unless the freq level is HF or DFT and a checkfile exists to start from; without one the route would fall back to guess=mix, whose deliberately symmetry-broken SCF is a different wavefunction than the one under test, and which would then report itself stable. THE DIAGNOSTIC DEFAULTS TO OFF, in both default_job_types and initialize_job_types. Enabling it by default would make every species wait on a job that only a Gaussian species can satisfy, and on a run restarted from a restart.yml written before this job type existed it would raise KeyError in check_all_done, which reads output['job_types'][job_type] before reaching the exemption and is not backfilled by initialize_output_dict. JobAdapter.as_dict also gains restricted_used, the SCF reference this job's input declared. It is the one job attribute a restart cannot rebuild: restore_running_jobs calls job_factory, which calls set_files, which composes the input file again and so calls is_restricted against the species' CURRENT state, so a restricted sp job queued before a reference decision changed would come back from a restart claiming to be unrestricted. Narrowing the docstring to admit the memo is only session-durable was rejected -- reading the memo instead of recomputing is the entire reason the per-job records are trustworthy, and a record that is right until the run is interrupted is not a record. ARC's end-of-run status report prints the stability summary string alongside a converged species, so the diagnostic is visible without opening output.yml. Dropping restricted_used from as_dict fails 1 test; the job-type registration is pinned by the job-type dictionaries in arc/main_test.py. The project forbids a file carrying both `import X` and `from X import Y` for the same X. arc/main_test.py, which this commit edits, carried `import unittest` alongside `from unittest import mock`. The submodule import is spelled `import unittest.mock` and its four call sites are qualified, so the file now imports `unittest` one way only. The base class also stops hard-coding the checkfile name. local_path_to_check_file was 'check.chk' for every adapter, which is a Gaussian name that psi_4 and terachem happen to share; ORCA writes its orbitals to a file named after the input file. JobAdapter now resolves two names per ESS: check_file_name, the file the ESS writes its orbitals to and the file that is downloaded, and guess_file_name, the name a previous job's orbitals are uploaded under. They differ only where an ESS cannot read and write one file the way Gaussian reuses a single checkfile. Both default to 'check.chk', so Gaussian, psi_4 and terachem are unchanged. check_file_name and guess_file_name are per-subclass class attributes on JobAdapter, overridden in OrcaAdapter, rather than a registry in the base class keyed by adapter name: the base class should not enumerate its subclasses, and the per-subclass attribute is the idiom job_adapter itself already uses. JobAdapter also gains readable_checkfile, which refuses a checkfile whose base name is neither the adapter's own check_file_name nor the '<prefix>_<check_file_name>' form ARC writes for a directed rotor. Scheduler hands every job the checkfile its species holds whichever ESS wrote it, so without this an ORCA input.gbw reaches Gaussian and is uploaded as check.chk and read with guess=read. The hazard predates this branch through terachem; this branch makes it live for the two ESSs people actually mix, so it is closed here.
Adds the ORCA side of the 'stability' job type: a single point at the frequency level and on the frequency geometry that adds STABPerform and STABRestartUHFifUnstable to the existing %scf block, reading the orbitals of the job under test. THE INSTABILITY IS ALWAYS FOLLOWED, and that is not a preference. With STABRestartUHFifUnstable false, ORCA 6.0.0 prints the verdict and the stability-matrix roots and then dies in LEANSCF with a BLAS incompatible-matrices error and mpirun exit code 62. Measured at eight processes and at one, and at six roots and at three, so it is not an MPI artifact; LeanSCF false does not help, failing earlier, in the SCF, before any verdict is printed. The three stable jobs run to a normal termination on the same settings, so it is the re-entry into LeanSCF after an instability that breaks. ORCA therefore has no equivalent of Gaussian's NoOpt, which reports an instability without following it. With the key true the job terminates normally and the log holds two analyses; the parser reads the verdict of the first, which is the wavefunction under test. A crashed job would additionally be misread by determine_ess_status, whose orca branch matches 'error termination in SCF' and not 'in LEANSCF', so the job would be an unrecognised error and the verdict never read. THE ORBITALS UNDER TEST ARE HANDED OVER, because ORCA's analysis is an SCF post-step: it converges an SCF first, and from its own initial guess that need not be the solution the frequency job reached. This is the hazard Gaussian's checkfile requirement exists to prevent, and ARC's scheduler already refuses to spawn the job unless the species still holds the checkfile its frequency job used. ORCA names its own orbitals after the input file, so it cannot read and write one file the way Gaussian reuses a single checkfile: the previous orbitals are uploaded as guess.gbw and read with !MORead and %moinp, while the job's own input.gbw is downloaded and becomes the next job's guess. The adapter adopts a checkfile on construction the way the Gaussian adapter does. INPUT.GBW IS DOWNLOADED ONLY WHERE SOMETHING READS IT. A def2-TZVP .gbw runs to tens of MB and the job type is off by default, so downloading one from every ORCA job would cost every run bandwidth and disk for a file nothing opens. It is fetched for the job types the guess chain actually reads from -- the opt, optfreq and composite jobs Scheduler.end_job adopts a checkfile from -- plus the stability job, whose own orbitals are the relaxed solution. A job array takes the data.hdf5 branch and fetches no orbitals at all, since its members share one remote path; the docstring now says so rather than leaving it to be inferred. EVERY ORCA JOB THAT RUNS AN SCF READS THE GUESS, as every Gaussian job carrying a checkfile gets guess=read. OrcaAdapter.reads_orbital_guess is the single predicate behind both halves of it, the !MORead and %moinp keywords and the guess.gbw upload, so the file is uploaded for exactly the jobs that read it; emitting the keywords without the file aborts the job on a missing guess. ORBITALS_GUESS_JOB_TYPES holds the job types this adapter writes an SCF on one starting structure for: opt, conf_opt, optfreq and scan, whose first SCF the guess seeds and whose later points ORCA propagates orbitals through itself, and freq, sp, conf_sp and stability, each a single SCF. The rest are the job types write_input_file emits no keyword for, so ORCA is handed no calculation for a guess to seed -- composite, for which ORCA offers no composite method; irc and orbitals; and directed_scan, for which this adapter writes neither the scan block nor the constraints such a job needs -- plus gen_confs, tsg and onedmin, which belong to other adapters. A job array is excluded because it writes no input file and its members share one remote path, where one uploaded guess would stand in for every member, and a monatomic species because ARC spawns it neither an optimization nor a frequency job. WHAT THE CHAIN BUYS IS MEASURED. On a C5H10 TS at UKS B3LYP/def2-TZVP, same geometry and same input but for the guess, a fresh guess collapsed to the closed-shell solution (E = -196.344572 Eh, <S**2> = 0.000000) while !MORead held the broken-symmetry solution (E = -196.364789 Eh, <S**2> = 0.864739), reproducing the followed solution to 1e-9 Eh -- 12.7 kcal/mol apart. Without the chain the freq job converges from ORCA's own initial guess while the stability job reads the optimization's orbitals, and those are two different SCF solutions in exactly the cases the analysis exists to find. NO LEVEL OR BASIS IS TRACKED, because ORCA projects a guess written in another basis onto the basis of the job reading it. A def2-SVP job reading a def2-TZVP .gbw logs 'Atom 0: N(Shells)= 6 and 11 - projection required' and terminates normally at a sane def2-SVP energy, so the chain crosses the basis change ARC makes between the optimization and the single point on its own. The predicate is whether a checkfile this ESS wrote exists, and no new state is stored on the species, in the restart dict or in output.yml. The single point runs on defgrid3, the grid a frequency job uses, rather than the defgrid2 an sp would take, so the SCF under test integrates on the grid the Hessian was built on. The input template gains two placeholders that render empty for every other job type, so every existing ORCA input is emitted byte for byte as before. The test module's project directory moves from a shared path under arc/testing to a private tempfile.mkdtemp(), which is what the project requires of writable test scratch. It adopts the exact form open PR #1008 uses for the same file -- cls.scratch_dir = tempfile.mkdtemp(prefix='arc_test_orca_') with project_directory=os.path.join(cls.scratch_dir, 'test_OrcaAdapter') -- so the two PRs' overlapping lines are textually identical and merge without conflict.
…d not
The stability job was added as a pure diagnostic, and the argument that nothing should
branch on its verdict was about TS SELECTION: switching guesses on an instability would
burn the guess list to no effect, since within one reaction six geometries shared an
eigenvalue to seven decimals, and it would bias rather than filter, since in one
reaction the three lowest saddles were the unstable ones. None of that is touched. No
guess is rejected, no job is re-run, and no check gates on the verdict. What changes is
the one thing the diagnostic is direct evidence for and nothing else in ARC measures:
whether the restricted reference is the ground state.
THE CONTRACT.
1. A user-declared number_of_radicals ALWAYS wins, and is never overwritten by a
calculation.
2. ARC still runs and still assesses the check when the user declared a value.
3. Disagreement is a WARNING, never a crash. Both pictures are recorded; the user's is
the one used.
4. With nothing declared and an external (R -> U) instability of a RESTRICTED reference
reported, ARC adopts the measured verdict for the reference decision on subsequent
jobs and records the provenance.
WHERE THE DERIVED VALUE LIVES. NOT in number_of_radicals. That field is read in 13
places, 8 of them molecular-graph perception and validation (six sites in species.py and
the scheduler's four n_radicals= call sites), plus xtb_adapter.py's
`uhf = number_of_radicals or multiplicity - 1`. A measured SCF property must not steer
graph perception or an xTB UHF count, so a declared radical count and a measured
wavefunction verdict cannot share a field. ARCSpecies gains
derived_stability_verdict instead, defaulting to None, serialised through as_dict /
from_dict only when set. It is deliberately not an __init__ keyword: it is not user input
and there is no way to declare it. The name avoids
output[label]['wavefunction_stability'], which is the summary STRING the run report reads
and is a different object. ARCSpecies also gains scf_references, the per-job-type record
of which reference each completed job actually declared.
is_species_restricted consults the verdict LAST. The multiplicity > 1 branch is untouched,
the signature and the species=None fallback are unchanged, and the new branch is reached
only when number_of_radicals is None. Only an EXTERNAL instability of a RESTRICTED
reference flips it. An INTERNAL instability must never flip the reference: it is a lower
solution inside the reference's own spin symmetry, which is a different problem and is not
evidence of broken-symmetry character. Nor does an external instability of an
already-unrestricted reference, which says nothing about a restricted one.
THE PRECEDENCE IS WRITTEN ONCE. adopted_reference_is_unrestricted is the predicate for
"a verdict ARC acts on", and it is the only place the rule that a declared
number_of_radicals of ANY value blocks adoption is stated; is_species_restricted,
open_shell_character_source and the scheduler's TS-switch carry all read it rather than
restating it. A declaration of 0 or 1 asks for a restricted reference, so it blocks the
verdict as surely as a declaration of 2 imposes an unrestricted one; a predicate that only
checked for "no declaration at all" would carry a verdict across a TS switch for a species
that will never run on it.
MEASUREMENT WIDENS TO EVERY RESTRICTED SPECIES. ADOPTION STAYS TS-ONLY. These are two
decisions, and left alone the first would have made the second for free.
Variationally E(UKS) <= E(RKS), with equality if and only if the restricted solution is
stable. So for a stable closed-shell species the two references give the same number, and
a stability analysis is the only thing that says whether holding a species restricted
changed its energy at all; an already-unrestricted job is free to break spin symmetry and
has nothing to learn from the test. That is why the measurement gate admits a RESTRICTED
reference rather than testing multiplicity: job_scf_reference_is_restricted reads the
optimization job's own restricted_used memo, which is what that input actually declared,
whereas recomputing answers what the species would get today. `is True` and not truthiness, because
the helper returns None for a job with no memo, so a pipe task is refused rather than
admitted by accident. Force field, composite and semiempirical levels are excluded:
is_species_restricted returns True for them before any other consideration and ARC writes
no r/u prefix, so their flag is not a reference choice ARC made. A multi-species memo is a
list, not a decision, and is refused. record_scf_reference had open-coded the same two
exclusions and now calls this helper instead of carrying a second copy.
Adoption is refused for a well, deliberately. Acting on a verdict means re-optimizing on
the lower solution and running every job after it there, and the energy that produces is a
broken-symmetry one: spin-contaminated and unprojected, so it still sits above the
spin-pure energy of the state it is reported for. The blast radius of
writing such a number differs in kind between the two cases. A TS's energy prices one
barrier; a well's prices its own thermo and every reaction it appears in, through Arkane
and through AEC/BAC corrections parameterised against the reference ARC normally picks. So
a well is not moved onto a contaminated surface on the strength of a measurement of its
reference alone, while a TS, which has no thermo of its own and whose remaining jobs'
reference is the decision the analysis informs, is. Declaring number_of_radicals = 2 runs
a well unrestricted from its first job, consistently -- the widened diagnostic is exactly
what tells a user to do that. So "derived" stays a property of the verdict and "adopted"
is the verdict ARC acts on, and adoption is a TS's undeclared verdict only.
WHAT A WELL'S VERDICT IS FOR, GIVEN THAT IT WILL SAY 'stable'. This is not a hunt for
instabilities. Well under a few per cent of closed-shell equilibrium geometries are
RHF -> UHF unstable; a stretched partial bond at a saddle is where the instability lives.
The diagnostic is worth its cost for what a 'stable' verdict LICENSES: a well verified
stable has identical restricted and unrestricted energies, so a barrier or reaction energy
taken between it and a TS that ARC has made unrestricted is a difference on one surface
rather than a comparison across two. Second, it catches an undeclared singlet biradical,
whose restricted energy is simply wrong and which nothing else in ARC detects. The
docstrings say this, so that a long run of 'stable' verdicts is read as the diagnostic
working rather than as it having nothing to do.
open_shell_character_source reports 'declared' only ABOVE one. Zero and one attribute no
open-shell character beyond the multiplicity -- is_species_restricted turns a declaration
into an unrestricted reference only at 2 -- so naming them as the source contradicts what
the function is for. They report None while declared_number_of_radicals still carries the
value, so output.yml says both that nothing was attributed and that a declaration was
nevertheless present, which is what blocked the measured verdict.
is_restricted memoizes its decision on the job adapter as obj.restricted_used. Adapters
call it while writing their input, so the memo is the reference that job's input actually
declared.
THE COMPOSITE EARLY RETURN IS ASSESSED, NOT CHANGED. is_species_restricted returns True for
force_field, composite and semiempirical levels before any multiplicity check, which makes
'uCBS-QB3' unreachable through this path. A derived instability on a composite-method
species is therefore ignored, and bypassing is deliberately not done here: it would change
composite energies repo-wide, since Gaussian's CBS-QB3 already selects UHF internally above
multiplicity 1 and forcing the prefix applies UHF to every step of a recipe whose
extrapolation and empirical corrections are parameterised against the standard one. It
would also be worst exactly here -- the stability job runs only at DFT or HF levels, so a
composite-level species can carry a verdict only when its freq level is DFT while its sp
level is composite, and bypassing would flip the reference of the run's most consequential
energy on a diagnostic measured at a different level of theory. Pinned by a test so a later
bypass is a deliberate act.
WHAT AN ADOPTED ENERGY IS NOT. A broken-symmetry energy is not a spin eigenfunction: it
mixes in the higher multiplicity, so it lies ABOVE the spin-pure low-spin energy, and the
restricted energy it replaces lies above the broken-symmetry one in turn. The ordering is
E_projected < E_BS < E_restricted, so an adoption is a step toward the spin-pure energy
that stops short of it rather than a step past it. ARC projects nothing, so the residual
error after an adoption is the contamination, not the reference, and it keeps the sign and
direction it had. That is said in adopted_reference_is_unrestricted rather than left for a
reader to infer from the absence of a claim.
Two test fixtures were also corrected, and neither is a behaviour change: the Gaussian
adapter helper built its species from a lone oxygen atom at multiplicity 1, i.e. singlet O,
when O(3P) is the ground state, and the species round trip hung an external instability on
ethane. Both are pure plumbing tests that never touch chemistry. They are water and ozone
now -- the second being the textbook closed-shell singlet with genuine diradical character,
which is the species the verdict under test would actually be measured on.
Verified by mutation. Deleting the derived branch from is_species_restricted fails 3;
ignoring the reference the verdict was measured on, 2; letting an internal instability flip
the reference, 3; dropping the declaration guard from adopted_reference_is_unrestricted, 1;
removing the is_restricted memo, 2, and renaming it, 2; dropping 'composite' from
REFERENCE_AGNOSTIC_METHOD_TYPES, 3; crediting any declaration as the source, 1, and
crediting a declared 1, 1; inverting the non-TS measurement admission, 3; deleting the
non-TS branch, 2; requiring a restricted reference of a TS as well, 4; admitting anything
not explicitly unrestricted, 2; reading a per-species list as one decision, 1; reading the
reference under a name other than the memo's, 7; widening adoption to wells, 5; and
dropping the is_ts term from adopted_reference_is_unrestricted, 1.
One mutant SURVIVES and is left unpinned: replacing
`job_scf_reference_is_restricted(job) is True` with plain truthiness. It is an equivalent
mutant -- the helper's isinstance guard means its codomain is exactly {True, False, None}
and the two spellings agree on every element of it. The `is True` is a guard against a
future widening of that return type, and is documented as such rather than tested.
REQUIRED AT MERGE WITH feature_gaussian_trsh_remedies, WHICH IS NOT EDITED HERE. That
branch gates guess=mix in arc/job/adapters/gaussian.py on
elif any(spc.multiplicity == 1 and spc.number_of_radicals is not None and spc.number_of_radicals > 1
for spc in self.species):
It needs the derived term, or the derived verdict will change the u prefix without changing
the guess keyword and the symmetry-broken SCF will have no broken guess to start from:
elif any(spc.multiplicity == 1
and ((spc.number_of_radicals is not None and spc.number_of_radicals > 1)
or adopted_reference_is_unrestricted(spc))
for spc in self.species):
adding adopted_reference_is_unrestricted to that file's existing
`from arc.job.adapters.common import (...)` block. No separate `number_of_radicals is None`
guard is needed on the derived term: the predicate carries it, which is the point of having
one predicate. It now also excludes a well, so a well cannot get a symmetry-broken guess for
a reference ARC did not change.
adopted_reference_is_unrestricted's docstring now says where the residual error lands, not
only that it exists. Adoption acts for a TS only, so a TS whose restricted reference was
unstable runs unrestricted while its reactants and products stay restricted; the
adopted TS energy still sits above the spin-pure one while the wells, whose restricted
references are stable, carry no such contamination, so the barrier is systematically
OVERestimated by the residual contamination of the TS -- less so than the all-restricted
barrier it replaces, which sat higher still. The direction is what a user meets, and it was
the one thing the paragraph did not state.
… in output.yml Three additions to the per-species output entry, all written from parsed logs. wavefunction_stability, the human-readable summary string the end-of-run report prints, plus the structured block behind it. _parse_wavefunction_stability reads the stability log and records the verdict, the negative stability-matrix eigenvector labels and their eigenvalues, the lowest eigenvalue, the reference the analysis ran on, and invalidates_analytic_freq, together with the run-relative path of the log it came from. It is not recomputed here: the parser derives the verdict and its consequence once, and this entry reads that single result. scf_reference, the provenance block. It records source (declared / derived / null), declared_number_of_radicals, verdict, verdict_restricted, sp_reference, freq_reference, reference_mismatch and the stability log it was read from -- flat, so that arcbench's _spin_diagnostic_payload allow-list is unaffected. reference_mismatch is null, NOT false, when either reference is unknown. A job carrying no reference memo leaves nothing to compare, and publishing that as false is indistinguishable from two references checked and found to agree. Every sibling key in the block uses null for unknown; this one does too. log names the analysis a 'derived' source was decided from even after the stability path is gone. A TS switch resets that path while the species keeps the verdict it carried across, so reading the path alone published a source of 'derived' beside a null log -- a decision with nothing behind it. The block falls back to the log path the species' own verdict carries, made run-relative like the other one. The block is NOT gated on convergence, unlike the parsed quantities beside it and like the wavefunction_stability entry it explains. It records a decision ARC made rather than a number a job produced, and a species that adopted a verdict and then failed to converge is precisely the case where knowing ARC changed its reference explains the failure. Gating it hid that record exactly when it was wanted. A well's verdict reaches output.yml as verdict / verdict_restricted with source null, which the entry's is_ts separates from a TS's identical verdict reading 'derived'. No new key is added for that: the pair already says it, and a column computed from its neighbours is a liability. sp_spin_diagnostic, the <S**2> block, in the same shape and key names as the arcbench emitter that consumes it, so the two bind unchanged. Its fallback loop now stops at the first candidate path that EXISTS, not at the first that yields a value. Continuing past a log that exists but yields no <S**2> is not what the fallback was written for: an sp on Molpro or CFOUR, neither of which implements parse_s_squared and both of which therefore inherit the base class's None, together with opt and freq on Gaussian UHF, populated sp_spin_diagnostic from the FREQ log at a different level of theory while d['sp_log'] still named the Molpro file -- and arcbench's TCKDB adapter uploads that block as the sp calculation's <S**2>. A present-but-silent log now ends the search and the block is omitted, and the block records the run-relative path of the log it was actually read from under 'log', following the precedent _parse_wavefunction_stability sets at the same call site. The new key is additive and safe for the arcbench consumer, which copies an allow-list out of the block and ignores every other key. Verified by mutation. Reading a non-dict scf_references with .get fails 1; publishing an unknown reference_mismatch as false, 1; gating the provenance block on convergence, 1; never reporting reference_mismatch, 1; never naming a source in the provenance block, 3; recording the stability block for a TS only, 2; reversing the sp/freq/opt fallback order, 2; and dropping the `if converged else None` gate on sp_spin_diagnostic, 1. _parse_wavefunction_stability's documented return schema was missing keys it already emitted. n_analyses and followed_to_stable reach output.yml and are now documented, along with the new s_squared_after_follow and the 'unattributed_instability' verdict. The note also records which wavefunction each field describes: verdict, lowest_eigenvalue, negative_eigenvectors and restricted belong to the wavefunction under TEST, while the energies and spin values the published log also holds belong to the FOLLOWED solution the ESS relaxed into. Nothing consumes the followed energy today, but output.yml publishes that log's path, so a future consumer would read the wrong wavefunction off it in silence.
…rdict, carry it across a TS switch
The scheduler side of the wavefunction-stability diagnostic: where the analysis sits in a
species' job sequence, what an adopted verdict does to that sequence, and what survives a TS
guess switch.
SEQUENCING. The analysis is a single point, so it can run the moment the optimization
converges, and that is where spawn_post_opt_jobs runs it:
opt -> stability -> freq / sp / IRC / rotors, or
opt -> stability -> opt (unrestricted) -> freq / sp / IRC / rotors
Nothing else is enqueued from the post-opt path while the analysis is out. The frequency
job, the single point, the IRC and the rotor scans all inherit the SCF reference and the
geometry of the optimization, so a reference that is not the ground state is caught before a
Hessian and an energy are spent on it, and before a re-optimization would have to throw them
away. The optimization job's name is written to the species as stability_pending_opt_job
BEFORE the analysis is spawned, and spawn_post_stability_jobs re-enters spawn_post_opt_jobs
with it; the analysis runs at most once per species, so the re-entry spawns none and falls
through. That resume is reached for every stability job that leaves running_jobs -- converged,
errored, or holding a log no verdict could be read from -- so an analysis that produces
nothing releases the species rather than stranding it.
RE-OPTIMIZATION IS WHAT MAKES AN ADOPTION CORRECT. Adopting a verdict without re-optimizing
would leave the geometry a stationary point of the RESTRICTED surface while the Hessian is
built on the broken-symmetry reference, which is a Hessian at a non-stationary point and can
report imaginary modes belonging to the mismatch rather than to the molecule. So an adopted
verdict re-runs the opt at the opt level, from the geometry the first optimization reached,
and is_species_restricted reads the adopted verdict off the species, so that job and every
job after it run unrestricted. E0 is then E_elect and ZPE from one surface.
AT MOST ONE RE-OPTIMIZATION PER SPECIES. ARCSpecies carries stability_analysis_ran,
stability_pending_opt_job and stability_reoptimized, all serialised through as_dict /
from_dict, so a run resumed between the analysis and the re-optimization spawns neither a
second analysis nor a second optimization. schedule_jobs releases any species holding a
pending optimization with no analysis of its own still queued, which is the state a run
resumed after its analysis ended leaves behind: the job it was waiting on is gone, so nothing
else would reach the resume for it.
THE ORBITALS THE RE-OPTIMIZATION STARTS FROM depend on what the ESS did with the instability
it found, which the verdict reports as followed_to_stable. ORCA runs STABPerform with
STABRestartUHFifUnstable, follows the instability, and writes the relaxed orbitals to the
analysis job's input.gbw; those are the broken-symmetry solution the re-optimization is meant
to sit on, so the species adopts them. Gaussian's stable=(rext,noopt) reports an instability
without following it, so its checkfile still holds the restricted orbitals, and handing those
to an unrestricted SCF returns it to the very solution the analysis rejected -- a restricted
solution is a stationary point of the unrestricted equations too. The checkfile is dropped in
that case and the job runs guess=mix, whose deliberately symmetry-broken guess is what finds
the lower solution.
SPAWNING GUARDS. Gaussian and ORCA only, DFT or HF only, the species must still hold the
checkfile its own optimization wrote, at most one analysis per species, and an early return
on job_types['stability'], which ships False, so a run that does not ask for this changes in
no way. The admission gate is `is_ts or job_scf_reference_is_restricted(opt_job) is True` --
a TS always, and any other species whose optimization actually ran on a restricted reference,
since an already-unrestricted job has nothing to learn from the test. A job that is not a
submitted ESS job carries none of this and is refused, so a pipe task releases the species
rather than holding it.
Every guard reports why it declined, including the ESS one. A job that ran in an ESS ARC has
no reader for is refused with a warning naming the species and that ESS, and saying that ARC
implements the analysis for Gaussian and ORCA only -- not that the ESS cannot perform one,
which would send the user looking in the wrong place. It is a warning rather than an info
line because the other refusals are per-job conditions that leave the feature working
elsewhere in the same run, whereas this one means the job type the user switched on will
never run for any species that ESS handles. It is emitted once per ESS per run, recorded in
Scheduler.stability_unimplemented_ess, since the condition is a property of the run and not
of the species that happened to reach it first; a 200-species project therefore gets one
line, not 200, and a mixed-ESS project one line per ESS.
CONSUMING. check_stability_job parses the verdict, writes it to the species and to
output.yml, and logs it: a warning naming the negative root and its eigenvalue for an
instability, an info line for a stable verdict. A restricted external instability does not
set invalidates_analytic_freq -- but it is not merely 'a statement about the restricted
description, not about the Hessian'. A Hessian built from that description inherits its
error: it is a correct second derivative of the surface that was computed, but that surface
is not the ground state, and near an RHF -> UHF instability onset the restricted surface is
spuriously stiff along the bond-stretching coordinate, which for a TS is the reaction
coordinate, so the imaginary frequency and the barrier curvature are wrong in a known
direction, too large and too high. Running the analysis before the Hessian is what keeps that
Hessian from being computed at all.
A campaign scan of every geometry-deduplicated restricted-singlet TS, 56 in all, found 12
unstable across 4 of 19 reactions, eigenvalues from -0.015 to -0.064 Hartree. Every one is
an RHF -> UHF instability with a triplet negative root; the scan turned up no singlet
negative eigenvalue at all. That scan is also why no guess is rejected on the verdict:
within one reaction the six unstable geometries share an eigenvalue to seven decimals, so
they are one stationary point found six ways, and rejecting would bias rather than filter --
in one reaction the three lowest-energy saddles are the unstable ones and the only stable one
is the highest, while in another the unstable ones sit 61 kcal/mol above the stable set.
log_open_shell_character_sources reports, per species, what was measured and whether ARC is
acting on it. A well found unstable is told in words that ARC is NOT acting on it, why not,
and what to declare to act on it. That last part names `number_of_radicals = 2`, not "a
number_of_radicals": the code reads a declaration as open-shell character only ABOVE one, so
a user who followed generic advice literally with 1 would get a restricted reference and
silence.
THE PER-JOB REFERENCE RECORDS remain, and their subject is narrower than the sequencing
above. record_scf_reference files each completed job's own restricted_used memo under
species.scf_references, so it reports what ran rather than what would run now.
SCF_REFERENCE_JOB_TYPES maps a job type onto the two terms of an E0: 'sp' supplies the
energy, and 'freq' OR the combined 'optfreq' supplies the ZPE. optfreq is not an
afterthought -- a guard listing only ['sp', 'freq'] silently drops every combined job, so a
species optimised and differentiated in one job records no ZPE reference at all and can never
report a mismatch. Reference-agnostic levels are not recorded, because comparing a CBS-QB3 sp
against a uwB97XD freq would report a mismatch that does not exist.
check_scf_reference_consistency raises a logger.warning naming both references and spelling
out E0 = E_elect(<sp>) + ZPE(<freq>), plus a persistent entry in the species' output
warnings. What it catches is a pair of jobs composed on either side of some other change to
the species' state: an sp resubmitted by troubleshooting, an sp held past its freq, or a
species restored from a restart. Its docstring says so rather than describing an adoption as
its subject, since a species that adopts a verdict re-optimizes and runs both terms on one
reference.
A STALE RECORD WAS A REAL SOURCE OF FALSE POSITIVES, and is fixed here. post_freq_actions
returns (True, switch_ts): freq_ok is True EVEN WHEN it switched the TS guess, and
check_freq_job recorded the reference one statement after switch_ts had deliberately cleared
scf_references. The abandoned guess' {'freq': 'restricted'} went straight back in, the next
guess' sp then recorded 'unrestricted' against it, and the mismatch warning fired for a guess
whose freq had in fact run unrestricted -- landing in output[label]['warnings'], which
delete_all_species_jobs does not reset. check_freq_job records only when the geometry
survived the check.
THE TS SWITCH CARRY RULE. carry_stability_verdict_across_ts_switch keeps an ADOPTED external
instability and drops everything else. Keeping it is NOT justified by any claim that an
instability is a property of the reaction rather than of one saddle -- the campaign data
refutes that: two of the four reactions carrying instabilities have MIXED verdicts across
distinct saddles. What justifies keeping it is that carrying it is FREE when it does not
apply. Variationally E(UKS) <= E(RKS) with equality if and only if the restricted solution is
stable, so if the next guess is in fact stable, forcing it unrestricted returns exactly the
restricted energy and costs SCF effort, not accuracy. What it buys is that the next guess is
unrestricted from its very FIRST optimization, which is the reference the discovering guess
reaches only by being optimized twice: a carried verdict spares the next guess that second
optimization and the analysis that would have prompted it. The pending optimization is
released at the same point, since switch_ts abandons that job along with the geometry it
converged to.
What is dropped in every case is the geometry-specific detail -- the negative eigenvector
labels and eigenvalues, the relaxed constraints, the lowest eigenvalue and
invalidates_analytic_freq all describe the abandoned wavefunction and its Hessian, and no
measurement of them exists for the new guess, since stability_analysis_ran stays set across
a switch. A carried verdict keeps verdict and restricted, plus measured_on_ts_guess
naming the guess it came from and the path of the analysis log it was read from, both of
which output.yml reports. 'stable', 'unknown', an internal
instability and an external instability of an unrestricted reference are dropped outright:
none decides a reference, and carrying one would attribute a bill of health to a geometry
never tested. So is a verdict ARC would not adopt because a number_of_radicals was declared
-- the declaration decides the reference, and carrying the verdict would promise the next
guess a change that is not coming.
species.scf_references is cleared outright at a switch, and so is the mixed-reference warning
they raised: opt, freq and sp all re-run for the new guess, so the abandoned guess' per-job
records describe nothing. The invalid-Hessian and spin-contamination warnings go with them,
for the same reason and about the same jobs.
THE TWO RECORDS OF A SWITCHED-AWAY GUESS ARE REDUCED TOGETHER. output[label]
['wavefunction_stability'] and the sentence the verdict added to output[label]['info'] are
top-level keys that nothing cleared, while delete_all_species_jobs resets
output[label]['paths'] at the same switch. So output.yml reported no verdict for the
surviving geometry -- it reads the path -- while the end-of-run summary printed the abandoned
guess' summary string against it, down to the negative root: 'external_instability
(Triplet-A, -0.0642)', naming a geometry that no longer exists. Both are cleared where the
species' own verdict is reduced, so the two records say the same thing about the same
geometry.
THE CALL ORDER INSIDE switch_ts IS NOW PINNED. measured_on_ts_guess is read off
species.chosen_ts, so carry_stability_verdict_across_ts_switch has to run BEFORE
determine_most_likely_ts_conformer picks the replacement, or the carried verdict is
attributed to the guess that had not been measured. Swapping the two left the suite green:
the test covering the path mocked determine_most_likely_ts_conformer out entirely, so
chosen_ts never moved and the ordering was invisible. The replacement lets the selection
actually change chosen_ts and asserts the carried verdict still names the abandoned one,
which fails when the two calls are swapped.
THE MIXED-REFERENCE CHECK NO LONGER MISSES THE MOST COMMON CONFIGURATION. record_scf_reference
was reachable from check_freq_job and check_sp_job only, and when the sp level equals the opt
level run_sp_job takes its equal-level branch and calls post_sp_actions directly -- no sp job
is submitted and check_sp_job is never reached. So scf_references['sp'] was never written for
a single-level run, check_scf_reference_consistency returned at its first guard forever, and
reference_mismatch was permanently null. The recording moves into post_sp_actions, which is
the one point both paths pass through, and takes the job whose log the energy is actually read
from: the sp job where one ran, the optimization job where none did. record_scf_reference
takes the key explicitly for that case, since an opt job's type does not say which term of the
E0 it supplied.
AN INVALIDATED ANALYTIC HESSIAN REACHES THE USER. invalidates_analytic_freq was parsed,
recorded and logged, and then reached no output the user reads: the mixed-reference case wrote
its message into output[label]['warnings'], which is what carries a warning into output.yml
and the run summary, while the invalid-Hessian case wrote only to ['info'].
INVALID_ANALYTIC_FREQ_MESSAGE now goes to ['warnings'] alongside it. No job flow changes:
nothing is re-run, re-referenced or invalidated, and the frequencies, the ZPE built from them
and the E0 built from that are reported as computed, with the warning attached.
SPIN CONTAMINATION IS SURFACED WHERE THE ENERGY IS READ. <S**2> was measured and published in
output.yml and compared against nothing: a doublet TS at 1.7488 against a spin-pure 0.75, 133%
contamination, passed as 'stable' with invalidates_analytic_freq False and its E0 went to
Arkane in silence. check_spin_contamination reads the diagnostic off the log the electronic
energy came from and warns, in the log and in the species' output warnings, when the deviation
from the spin-pure S(S+1) exceeds MAX_S_SQUARED_DEVIATION = 0.1. The threshold is an ABSOLUTE
deviation, not a fraction of the spin-pure value, because a singlet's spin-pure value is zero
and the broken-symmetry singlet is exactly the case that most needs reporting. Its size
follows from what a deviation means: the nearest contaminant of a state of spin S is the state
of spin S+1, whose S(S+1) lies 2S+2, at least 2, above it, so 0.1 is at most a five percent
admixture. A converged doublet at 0.7536 and a triplet at 2.0086 stay silent; an adopted
broken-symmetry singlet does not, which is the point. A restricted reference prints no <S**2>
and an ESS with no reader for it reports none, and both are passed over rather than reported
uncontaminated. Job flow is unchanged here too.
The ORCA stability job emits %moinp "guess.gbw" and uploads guess.gbw to the remote job path, but every ORCA template in this file copied only the input file into the scratch $WorkDir, and `grep -n gbw arc/settings/submit.py` returned nothing at all. With ARC's repo defaults ORCA therefore aborts with `Cannot open file guess.gbw` on every stability job, determine_ess_status classifies that as errored/['Unknown'], and ARC troubleshoots a deterministic failure for as long as the run lasts. The feature only worked on the machine it was developed on because that machine's ~/.arc/submit.py overlay happens to glob *.gbw, and a repo settings value says nothing about production: the overlay shallow-replaces whole dicts, so what a developer runs and what this branch ships are different files. Every ORCA template now copies guess.gbw in and input.gbw back out, in each template's own style: the three that name the files they return copy both explicitly, the two that copy the whole work directory back need only the inbound line, and the HTCondor job.sh lists input.gbw beside the input.log and input_property.txt it already names. Both copies are tolerant of an absent file -- most jobs hand ORCA no guess, and a job that died may have written no orbitals -- so a missing file writes nothing to err.txt and does not affect the exit status. This mirrors the Gaussian template, which has copied check.chk in for as long as guess=read has been emitted.
Psi4 is the one ESS adapter that does not route its construction through _initialize_adapter, so the guard JobAdapter.readable_checkfile applies to every other adapter did not reach it. Scheduler hands every job the checkfile its species holds whichever ESS wrote it, and this adapter assigned it unconditionally and then uploaded it as check.chk, so a species optimized in ORCA would have had its input.gbw handed to Psi4 under a Gaussian name. The assignment now goes through the same guard, which is a one-expression change and leaves every other line of the adapter alone.
Adds ``stability`` to the job type list in the advanced documentation, and to the two job-type dictionaries the page shows as examples, so that a reader copying either gets a dictionary matching the one ARC now builds. The entry states the whole user-visible contract in one place: the job runs in Gaussian and ORCA and is off by default; it runs from the optimization, ahead of the frequency job, the single point, the IRC and the rotor scans, all of which are held for its verdict; it runs for a TS and for any other species whose optimization ran with a restricted reference, which is the only reference it can inform; a TS's external instability of a restricted reference re-optimizes the species unrestricted from the geometry the first optimization reached, at most once per species and recorded in the restart file; a declared ``number_of_radicals`` always wins; and for anything that is not a TS the verdict is reported and nothing acts on it. It also states which orbitals the re-optimization starts from and why the answer differs by ESS: ORCA follows the instability and writes the relaxed broken-symmetry orbitals, which are the ones to start from, while Gaussian's ``stable=(rext,noopt)`` leaves its checkfile holding the restricted solution, which an unrestricted SCF reading it would simply return to, so the checkfile is dropped and the job runs ``guess=mix``. It also says what a long run of ``stable`` verdicts means, so that it is read as the diagnostic working rather than as it having nothing to do: a well verified stable has identical restricted and unrestricted energies, so a barrier taken between it and a TS ARC has made unrestricted is a difference on one surface rather than across two, and an undeclared singlet biradical is caught by nothing else in ARC. And it says what an adopted verdict does not buy. The broken-symmetry solution is not a spin eigenfunction; it mixes in the higher multiplicity, so its energy lies ABOVE the spin-pure low-spin energy, and the restricted energy it replaces lies above the broken-symmetry one in turn: E_projected < E_BS < E_restricted. An adoption is therefore a step toward the spin-pure energy that stops short of it. ARC does not project the contamination out; ``arc/checks/spin.py`` holds the Yamaguchi arithmetic that estimates E_projected, so the residual error after an adoption is the contamination itself, in the direction it already had. The size of that residual is not left to be inferred either: an ``<S**2>`` deviating from its spin-pure S(S+1) by more than 0.1 is warned about where the electronic energy is read. The advice to declare ``number_of_radicals = 2`` names the value rather than the key, because ARC reads a declaration as open-shell character only above one. Also documents the ORCA path: why the instability is always followed rather than only reported, that the resulting log holds two analyses and which one is the verdict, how the orbitals under test are handed over given that ORCA names its own orbitals after the input file, and why an ORCA stable verdict on an unrestricted reference says less than a Gaussian one. Corrects the claim that the two codes do not test the same space. They do: Gaussian's Stable=RExt uses the same Ms-conserving <AA,BB:AA,BB> singles matrix ORCA uses, for both references, and neither reaches the GHF sector, so neither verdict is the weaker one. The comparison that produced the original claim was also run at unmatched functional -- plain ORCA B3LYP is VWN-5 against Gaussian's VWN3 -- and at matched functional the roots agree to under 0.4% wherever both codes found the same SCF solution. The one system that still disagrees is a near-dissociated radical pair on which the two codes converged to different UHF solutions, and it supports no cross-code conclusion. The section also now records how a restricted reference's sector is measured from the followed solution's <S**2>, that a barrier taken across an adopted verdict is OVERestimated in a known direction by the TS's residual contamination -- less so than the all-restricted barrier it replaces, which sat higher still -- that neither code computes a spin-flip root for an unrestricted reference so both readers leave the external sector undetermined there, that both ESSs chain their orbitals from the optimization so the tested wavefunction is the optimization's, which ORCA job types read a guess and what the chain is worth on a broken-symmetry TS, and that a site running its own submit.py must copy guess.gbw in and input.gbw out.
input_reference.rst had no mention of `stability` at all, and its job_types example listed six keys out of twelve, so the only place the feature was documented was advanced.rst -- which describes behaviour and is not where a user goes to find out which keys an input file accepts. The feature was therefore discoverable only by reading the settings module. `stability` is added to the job type key list and to the example with its default, and the example now states which keys default to true and which to false, since the example itself lists a mixture of the two and previously implied that the ones it omitted were unavailable rather than defaulted. A short section states what the analysis is, that ARC has implemented it for Gaussian so far and that other ESSs are not wired up yet, when it runs (once per species, after that species' freq job, for a TS or for a species whose freq job actually ran restricted, at a DFT or HF level, with the freq job's checkfile), and what the user gets back. Which instabilities invalidate analytic frequencies, and what an adopted verdict does and does not correct, stay in advanced.rst; this section links there rather than restating them. The `specific_job_type` interaction is documented because it is a trap. That key replaces job_types wholesale with a dictionary in which only the named type is true, so `specific_job_type: stability` is accepted -- stability is a key of default_job_types, so no InputError is raised -- and then produces nothing at all, because opt, freq and sp are all false and run_stability_job is only ever reached from post_freq_actions. bde is special-cased to re-enable opt/fine/freq/sp; stability is not. Verified against initialize_job_types rather than inferred: specific_job_type 'stability' yields opt False, freq False, sp False, stability True. The reference now says to request it through job_types and says why. Deliberately not documented here: which other programs implement a stability analysis and under what keyword. ORCA and Q-Chem both do, but their exact syntax was not verified against their manuals for this commit, and an unverified keyword in ARC's documentation is worse than none. The text is kept to what ARC does. Also records that the job type now runs in ORCA as well as in Gaussian, that ORCA always follows an instability it finds, and that the two codes test the same space and agree on the verdict, with the sector of a restricted reference's instability read off the solution ORCA relaxes into.
f9c0a3a to
afc47f9
Compare
Thanks. Please note that the the branch has moved quite ahead since this review was enacted - I believe even when reviewed it had already moved a few commits ahead. So this will require another thorough review.
Fixed: 5, 6, 7, 8, 9, 10, 11, 12, H1, H2, H5, H6 Also, just for clarification:
|
Teaches ARC to test whether the SCF reference it chose is actually the ground state, and to act on the answer. Gaussian and ORCA.
Off by default (
'stability': Falseinsettings.py) — a run that does not ask for it is unchanged.What it adds
An opt-in
stabilityjob type, spawned once per species when its optimisation converges — for every TS, and for any other species whose opt actually ran restricted, which is the only reference the analysis can inform. The analysis is a single point, so it needs no frequencies.The optimisation's tail — freq, sp, IRC, rotors and the rest — is held until the verdict is in, because each of those inherits the optimisation's reference or its geometry:
An instability means the geometry is wrong too: it is a stationary point of the restricted surface only. So an adopted verdict re-optimises rather than merely re-referencing — a Hessian taken at the restricted geometry on an unrestricted reference sits at a non-stationary point and can produce spurious imaginary modes. At most one re-optimisation per species, guarded by
stability_reoptimizedonARCSpeciesand carried inrestart.yml, and a resumed run releases any species whose analysis finished while it was down.The re-optimisation's starting orbitals are per-ESS, because the two codes leave different things behind. ORCA follows the instability and writes the relaxed broken-symmetry orbitals, which seed the re-optimisation. Gaussian's
stable=(rext,noopt)does not follow, so its checkfile still holds the restricted orbitals — and a restricted determinant is a stationary point of the unrestricted equations, so reading it back converges to the solution the analysis rejected. That case drops the checkfile instead and takes Gaussian'sguess=mix.stable=(rext,noopt)at the freq level on the freq geometry.STABPerform true/STABRestartUHFifUnstable true, plus.gbworbital tracking.A parser reads the verdict, the negative stability-matrix roots and their eigenvalues, and derives whether the analytic frequencies are invalidated. An unreadable verdict reports
unknown, neverstable.Alongside it, the
<S**2>spin-contamination diagnostic is re-homed from an arcbench-based branch ontomain(Gaussian, ORCA, Q-Chem), andarc/checks/spin.pygains the Yamaguchi approximate spin-projection arithmetic.The adoption contract
number_of_radicalsalways wins and is never overwritten by a calculation.An internal instability never flips the reference — it is a lower solution inside the reference's own spin symmetry, which is a different problem and not evidence of broken-symmetry character.
The derived verdict lives in its own
ARCSpecies.derived_stability_verdict, deliberately not innumber_of_radicals, which feeds molecular-graph perception in eight places plus xTB's UHF count. A measured SCF property must not decide which molecule ARC thinks it has.Measurement widened to every restricted species; adoption stays TS-only. A well is never re-optimised on its verdict, so adopting for one would guarantee the
E_elect(unrestricted) + ZPE(restricted)splice a TS only risks. Because adoption is TS-only, a TS whose restricted reference was unstable runs unrestricted (broken-symmetry, contaminated, biased low) while its reactants and products stay restricted — so the barrier is systematically underestimated, and nothing projects. That direction is now stated in the docstring and inadvanced.rstrather than left implicit.Provenance — source, declared count, verdict, per-job-type SCF references, mismatch flag and the log it was read from — is recorded in
output.ymland survives a restart. A TS switch carries an adopted external instability forward, dropping everything geometry-specific.The ORCA path, and what had to be measured to write it
ORCA's manual documents the input keywords but not the output, so the parser could not be written from documentation. Thirteen ORCA 6.0.0 jobs were run (five fixtures, four diagnostics, four functional-matched controls) on the four geometries the Gaussian fixtures already use.
Verdict wording, which ORCA's manual does not publish:
STABRestartUHFifUnstable trueis mandatory, not a preference. With itfalse, ORCA 6.0.0 terminates inLEANSCFwith a BLAS incompatible-matrices error (exit 62) after printing a complete verdict — reproduced atnprocs 8andnprocs 1, atSTABNRoots6 and 3;LeanSCF falsefails earlier still. The three stable jobs terminate normally. ORCA therefore cannot do Gaussian'snoopt"report but do not follow", and ARC always tells it to follow.A followed log holds two analysis blocks with opposite verdicts.
verdictis taken from the first — the wavefunction under test. The second describes what ORCA relaxed into and is exposed separately asfollowed_to_stable. Gaussian'snooptlogs never exercise this.On the unstable singlet TS, following the instability gained −0.02022 Eh (−12.7 kcal/mol) and took
<S**2>from0.000000to0.864742— a genuine open-shell singlet whose restricted energy was wrong by 12.7 kcal/mol..gbwtrackingORCA's analysis is an SCF post-step: it converges an SCF first, so without reading the tested orbitals it may converge to a different solution — the hazard Gaussian's checkfile requirement exists to prevent. ARC had no
.gbworMOReadhandling.checkfilewas already ESS-generic (psi_4→check.chk,terachem→teracheck.chk), so this rides existing plumbing viacheck_file_name/guess_file_nameclass attributes onJobAdapter. ORCA names its own output after the input file, so it cannot read and write one.gbwthe way Gaussian reuses one.chk: the guess is uploaded asguess.gbwand read with!MORead/%moinp, while the job's owninput.gbwis what returns.Every ORCA job that runs an SCF on a single structure reads the guess when one exists —
opt,conf_opt,optfreq,scan,freq,sp,conf_sp,stability— mirroring Gaussian'sguess=read, which sits outside any job-type branch. Job types for which the adapter writes no calculation keyword, monatomic species, and job arrays (whose members share one remote path) read none. The emission set and the upload set are the same predicate,OrcaAdapter.reads_orbital_guess(), and a test asserts they agree across all 15 job types.This is what keeps the chain consistent. Measured on the C₅H₁₀ TS — identical
!UKS B3LYP def2-TZVPinput, same geometry, only the guess differing:<S**2>!MORead12.7 kcal/mol. The fresh guess collapses onto the closed-shell solution, which the stability analysis reports unstable at that geometry (lowest root −0.0647); reading the optimisation's orbitals holds the stable broken-symmetry solution. A basis change between jobs is handled by ORCA itself — a
def2-SVPjob reading adef2-TZVP.gbwlogsN(Shells)= 6 and 11 - projection requiredand converges — so no level tracking is required.The sector is measured, not assumed
ORCA prints one unlabelled stability matrix, which for a restricted reference spans both the internal (singlet) and external (R→U triplet) sectors. Assuming external would drive reference adoption and suppress the analytic-frequency warning on evidence the log does not contain.
Because ARC is forced to run the follow anyway, the post-restart
<S**2>is free and decides it: a nominal singlet reaching a stable solution aboveSPIN_SYMMETRY_BREAKING_S_SQUARED = 0.01proves the spin symmetry broke. Where the follow never converged, a new verdictunattributed_instabilityis reported with both flagsNone— never a fabricated external, and it deliberately does not trigger reference adoption.Cross-code validation
ORCA analyses RHF/RKS in UHF/UKS space and UHF/UKS in UHF/UKS space. Gaussian's
Stable=RExtuses the same Ms-conserving block — all four Gaussian fixtures printStability analysis using <AA,BB:AA,BB> singles matrix:— so the two tests span the same space and neither reaches the GHF sector.The two codes agree on the verdict for all four systems. Comparing eigenvalues requires a matched functional, since ORCA's
B3LYPuses VWN-5 and Gaussian's uses VWN3; ORCA's matching keyword isB3LYP/G:Total energies agree to 0.0002 Eh, so the functional accounted for the entire raw offset. The outlier is not a code difference: at matched functional its energies still differ by 0.025 Eh with
<S**2>1.7488 vs 1.700055, because the two codes converged to different UHF solutions of a near-dissociated O(³P)···CH₃ pair at r(O–C) = 3.78 Å. No cross-code conclusion is drawn from that system.What review changed
A shipping blocker.
arc/job/adapters/orca.pyemitted%moinp "guess.gbw"and uploaded the file, but no ORCA template inarc/settings/submit.pycopied it into the scratch working directory — Gaussian hascp "$SubmitDir/check.chk" .; every ORCA template copied onlyinput.in. With repo defaults any ORCA job reading a guess would abort with "Cannot open file guess.gbw", be classifiederrored/['Unknown'], and be retried forever. It passed development testing only because that cluster's~/.arc/submit.pyoverlay happens to glob*.gbw. All six templates now copyguess.gbwin andinput.gbwout; all 36 templates were re-checked to still.format().Two silent-corruption paths. A failed
.gbwdownload leaves a 0-byte file (paramiko opens the local file before the remote), which passedos.path.isfile()and was adopted asspecies.checkfile; size is now checked. And an ORCA.gbwcould be handed to Gaussian ascheck.chkwithguess=read, reachable wheneveropt_levelroutes to ORCA and a later job to Gaussian — adapters now refuse a checkfile that is not theirs, viaJobAdapter.readable_checkfile().A test-ordering bug of the kind that has been destabilising this suite. Five tests asserted
record.levelname == 'WARNING', butarc.common.initialize_logcallsaddLevelName(logging.WARNING, 'Warning: '). They pass alone and fail whenever anything initialises ARC's log first — which-n 6 --dist workstealcan arrange. They comparelevelnonow.Earlier rounds: the Gaussian pass refuted the premise the work started from (g16 does not ignore
guess=mixon a restricted reference —Mixing orbitals ... Coef= 7.07106781D-01on a closed-shell singlet, systematic across 24 fixtures). The chemistry pass caught the Yamaguchi arithmetic being silently singlet-only, hard-coding<S²>_LS = 0— a 16.8 kcal/mol error for a BS-doublet/HS-quartet pair. The adversarial pass measured the separation guard returning −101 Ha from a −1.0 Ha reference; the floor is now physical rather than a divide-by-zero guard, and NaN/inf are rejected.before-annihilationis the<S**2>fed to the projection, because that is the value the SCF energy belongs to — the annihilated one biases it +21.2 kcal/mol.Discoverability
run_stability_jobrefuses an unsupported ESS with a warning naming the species and the ESS, emitted once per ESS per run. It is a warning rather than an info because it means the job type the user explicitly switched on will never run for any species that ESS handles — a configuration mismatch, not a situational skip — and once per ESS so a 200-species project gets one line rather than 200.docs/source/input_reference.rstlistsstabilityamong the job-type keys and in thejob_typesexample, and states which keys default true and which false.Verification
11 commits, no file touched by more than one. Commit order is bisect-safe:
readable_checkfilesits inarc/job/adapter.pyso the adapter commits can use it without a forward dependency.Full suite, serial: 2992 passed, 36 skipped, 5 failed. The 5 are
arc/job/adapters/torch_ani_test.py, failing on an unconfiguredTORCHANI_PYTHON(arc_envhas no torchani; it lives ints_gcn); nothing here touches that adapter.🤖 Generated with Claude Code