Skip to content

Test whether the SCF reference is the ground state, and act on the answer - #1014

Open
calvinp0 wants to merge 11 commits into
mainfrom
feature_wavefunction_stability_check
Open

Test whether the SCF reference is the ground state, and act on the answer#1014
calvinp0 wants to merge 11 commits into
mainfrom
feature_wavefunction_stability_check

Conversation

@calvinp0

@calvinp0 calvinp0 commented Aug 22, 2026

Copy link
Copy Markdown
Member

⚠️ Changes a physical quantity. Reviewed by a quantum-chemistry pass as well as an adversarial code pass, three times each.

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': False in settings.py) — a run that does not ask for it is unchanged.

What it adds

An opt-in stability job 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:

opt → stability → freq / sp / IRC / rotors
opt → stability → opt (unrestricted) → freq / sp / IRC / rotors

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_reoptimized on ARCSpecies and carried in restart.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's guess=mix.

  • Gaussian: stable=(rext,noopt) at the freq level on the freq geometry.
  • ORCA: a single point with STABPerform true / STABRestartUHFifUnstable true, plus .gbw orbital 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, never stable.

Alongside it, the <S**2> spin-contamination diagnostic is re-homed from an arcbench-based branch onto main (Gaussian, ORCA, Q-Chem), and arc/checks/spin.py gains the Yamaguchi approximate spin-projection arithmetic.

The adoption 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 regardless.
  3. Disagreement is a warning, never a crash; both pictures are recorded.
  4. Only when nothing is declared does an external (R→U) instability of a restricted reference get adopted for subsequent jobs, with provenance recorded.

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 in number_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 in advanced.rst rather 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.yml and 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:

The stability analysis shows that the wavefunction is stable
The stability analysis indicates that the wavefunction is unstable

STABRestartUHFifUnstable true is mandatory, not a preference. With it false, ORCA 6.0.0 terminates in LEANSCF with a BLAS incompatible-matrices error (exit 62) after printing a complete verdict — reproduced at nprocs 8 and nprocs 1, at STABNRoots 6 and 3; LeanSCF false fails earlier still. The three stable jobs terminate normally. ORCA therefore cannot do Gaussian's noopt "report but do not follow", and ARC always tells it to follow.

A followed log holds two analysis blocks with opposite verdicts. verdict is taken from the first — the wavefunction under test. The second describes what ORCA relaxed into and is exposed separately as followed_to_stable. Gaussian's noopt logs never exercise this.

On the unstable singlet TS, following the instability gained −0.02022 Eh (−12.7 kcal/mol) and took <S**2> from 0.000000 to 0.864742 — a genuine open-shell singlet whose restricted energy was wrong by 12.7 kcal/mol.

.gbw tracking

ORCA'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 .gbw or MORead handling.

checkfile was already ESS-generic (psi_4check.chk, terachemteracheck.chk), so this rides existing plumbing via check_file_name / guess_file_name class attributes on JobAdapter. ORCA names its own output after the input file, so it cannot read and write one .gbw the way Gaussian reuses one .chk: the guess is uploaded as guess.gbw and read with !MORead / %moinp, while the job's own input.gbw is 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's guess=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-TZVP input, same geometry, only the guess differing:

E (Eh) <S**2>
ORCA's own initial guess −196.344572 0.000000
!MORead −196.364789 0.864739

12.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-SVP job reading a def2-TZVP .gbw logs N(Shells)= 6 and 11 - projection required and 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 above SPIN_SYMMETRY_BREAKING_S_SQUARED = 0.01 proves the spin symmetry broke. Where the follow never converged, a new verdict unattributed_instability is reported with both flags None — 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=RExt uses the same Ms-conserving block — all four Gaussian fixtures print Stability 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 B3LYP uses VWN-5 and Gaussian's uses VWN3; ORCA's matching keyword is B3LYP/G:

system Gaussian E(0) ORCA B3LYP/G E(0) ratio
stable restricted singlet 0.0241461 0.0242519 1.004
stable unrestricted doublet 0.0024619 0.0024662 1.002
RHF→UHF unstable singlet −0.0642219 −0.0641958 0.9996
spin-contaminated doublet 0.0005803 0.0657159 113×

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.py emitted %moinp "guess.gbw" and uploaded the file, but no ORCA template in arc/settings/submit.py copied it into the scratch working directory — Gaussian has cp "$SubmitDir/check.chk" .; every ORCA template copied only input.in. With repo defaults any ORCA job reading a guess would abort with "Cannot open file guess.gbw", be classified errored/['Unknown'], and be retried forever. It passed development testing only because that cluster's ~/.arc/submit.py overlay happens to glob *.gbw. All six templates now copy guess.gbw in and input.gbw out; all 36 templates were re-checked to still .format().

Two silent-corruption paths. A failed .gbw download leaves a 0-byte file (paramiko opens the local file before the remote), which passed os.path.isfile() and was adopted as species.checkfile; size is now checked. And an ORCA .gbw could be handed to Gaussian as check.chk with guess=read, reachable whenever opt_level routes to ORCA and a later job to Gaussian — adapters now refuse a checkfile that is not theirs, via JobAdapter.readable_checkfile().

A test-ordering bug of the kind that has been destabilising this suite. Five tests asserted record.levelname == 'WARNING', but arc.common.initialize_log calls addLevelName(logging.WARNING, 'Warning: '). They pass alone and fail whenever anything initialises ARC's log first — which -n 6 --dist worksteal can arrange. They compare levelno now.

Earlier rounds: the Gaussian pass refuted the premise the work started from (g16 does not ignore guess=mix on a restricted reference — Mixing orbitals ... Coef= 7.07106781D-01 on 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-annihilation is 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_job refuses 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.rst lists stability among the job-type keys and in the job_types example, 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_checkfile sits in arc/job/adapter.py so 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 unconfigured TORCHANI_PYTHON (arc_env has no torchani; it lives in ts_gcn); nothing here touches that adapter.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 22, 2026 10:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread arc/parser/adapters/qchem.py Fixed
Comment thread arc/parser/parser_test.py Fixed
@calvinp0
calvinp0 force-pushed the feature_wavefunction_stability_check branch 2 times, most recently from 32f6064 to ff61563 Compare August 22, 2026 13:43
Comment thread arc/parser/parser_test.py Fixed
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.02%. Comparing base (45d73a0) to head (afc47f9).
⚠️ Report is 1 commits behind head on main.

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     
Flag Coverage Δ
functionaltests 65.02% <ø> (+0.41%) ⬆️
unittests 65.02% <ø> (+0.41%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@calvinp0
calvinp0 force-pushed the feature_wavefunction_stability_check branch 3 times, most recently from 3eb7956 to 1c9636b Compare August 22, 2026 17:00
@alongd

alongd commented Aug 22, 2026

Copy link
Copy Markdown
Member

Reviewed at 32f60644aaa535eae16b01636c99ccf0fe595d17 against merge-base d285b1ed004bf3b7b5140b9da11986236ea4a8b1 — 33 files, +7378/−29, 16 source files across 9 packages.

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:

Score Established by
10 Executed. Output pasted in.
8–9 Read the specific lines and traced the path.
6–7 Read and reasoned; nobody ran the failure.
≤5 Pattern match; check before acting.

Fix first

1. The adopted unrestricted reference collapses back to restricted — the feature is currently a no-op

arc/job/adapters/gaussian.py:405 · confidence 10 — code path executed, SCF consequence measured

guess=read is emitted whenever a checkfile exists and guess=mix otherwise, chosen with no reference to restricted/unrestricted. The u prefix is decided ~30 lines later. So a post-adoption job reads a restricted checkpoint into a UKS calculation.

Composed inputs, with an adopted verdict on a singlet TS:

sp    chk=True  adopted=True  restricted_used=False
        #P integral=(grid=ultrafine, Acc2E=12) guess=read ub3lyp/def2tzvp ...
opt   chk=True  adopted=True  restricted_used=False
        #P opt=(calcfc,maxcycle=100,noeigentest,ts)  guess=read ub3lyp/def2tzvp ...
sp    chk=False adopted=True  restricted_used=False
        #P integral=(grid=ultrafine, Acc2E=12) guess=mix ub3lyp/def2tzvp ...

The line is pre-existing at the merge-base — this PR doesn't introduce it, it collides with it:

$ git show d285b1ed0:arc/job/adapters/gaussian.py | grep -n "guess=read' if self.checkfile"
396:  input_dict['job_type_1'] += ' guess=read' if self.checkfile is not None and os.path.isfile(self.checkfile) \

And the collision is guaranteed on exactly the path that needs the other branch: run_stability_job:1812-1816 requires species.checkfile to exist and match the freq job's before a verdict is recorded at all. species.checkfile is set on the first opt/optfreq/composite (scheduler.py:1275), cleared only by ESS troubleshooting (:4314), and not cleared by switch_ts. So at every adoption a checkfile is present and guess=mix is unreachable.

You already state the mechanism, at scheduler.py:1747-1750:

without a checkfile the route would fall back to guess=mix, whose deliberately symmetry-broken SCF is a different wavefunction than the one under test

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 measurement

PySCF 2.14, B3LYP/def2-TZVP, grids level 4, conv_tol=1e-9, on the 15-atom C5H10 TS geometry taken from the Input orientation block of arc/testing/stability/rhf_uhf_instability_singlet_ts.out. 215 basis functions.

--- RKS reference ---
E(RKS)              = -196.490056185  converged=True

--- UKS seeded from the converged RKS density  [guess=read analogue] ---
E(UKS | from RKS)   = -196.490056186  converged=True  <S^2>=-0.000000

--- UKS from a HOMO/LUMO-mixed start  [guess=mix analogue] ---
E(UKS | from mix)   = -196.510081567  converged=True  <S^2>=0.863364

================ VERDICT ================
E(UKS from RKS) - E(RKS) = -4.724e-10 Ha  (-0.0000 kcal/mol)
E(UKS from mix) - E(RKS) = -2.003e-02 Ha  (-12.5661 kcal/mol)
UKS-from-RKS collapsed back to the restricted solution : True
UKS-from-mix found a lower, broken-symmetry solution   : True
energy the adoption path leaves on the table           : -12.5661 kcal/mol

Three things this establishes:

  1. The setup is your fixture's. E(RKS) = −196.490056185 Ha against the fixture's own SCF Done: E(RB3LYP) = -196.490056259 (line 523) — agreement to 7.4e-8 Ha across two different programs and grids.
  2. A UKS calculation seeded from a converged restricted density returns the restricted energy exactly — 4.7e-10 Ha, ⟨S²⟩ = 0.000000. It doesn't resemble the restricted solution; it is the restricted solution, carrying an unrestricted label.
  3. A symmetry-broken start finds a genuine lower solution — 12.57 kcal/mol below, ⟨S²⟩ = 0.863. The RKS reference at this geometry really is externally unstable, exactly as the fixture's Gaussian verdict (Triplet-A at −0.0642 Ha) says.

The internal/external split agrees across both programs: PySCF's RKS.stability() returns (mo_i, mo_e, stable_i, stable_e) = (ndarray, None, True, None) — internal stability True, matching your stable Singlet-A, with the external test simply not run by default (that slot is None, not False; reading it as a negative is a trap I hit on the first pass).

Consequence. On this fixture, the adoption path produces −196.4901 Ha and records scf_reference: unrestricted, reference_mismatch: false, when the broken-symmetry solution it was reaching for is at −196.5101 Ha. The barrier is wrong by 12.57 kcal/mol, in the direction the PR set out to correct, with every record saying the correction was applied.

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 guess=read analogue is a hand-built density seed, not Gaussian's checkpoint reader. A g16 run would close that last gap.

Reproduce it

import 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 direction

On adoption emit guess=mix (or guess=(read,mix)), or add stable=opt to the first post-adoption job. Then assert s_squared > BROKEN_SYMMETRY_S2_THRESHOLD on that job's output and warn or re-run if it collapsed — otherwise the same silent failure returns the moment the guess logic is touched again.

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 — _parse_spin_diagnostic (arc/output.py:334) reports s_squared: 0.0 against s_squared_expected: 0.0 for a multiplicity-1 species, i.e. perfectly healthy, for exactly this failure.


2. Five of the new tests are order-dependent and will flake CI red

arc/scheduler_test.py:737,768,781,796,809 · confidence 10

arc/common.py:186-189, inside initialize_log():

logging.addLevelName(logging.WARNING, 'Warning: ')   # process-global, permanent

The five new tests assert r.levelname == 'WARNING'. Once any earlier test in the same process constructs ARC(...) — e.g. arc/reaction/reaction_test.py:1054,1075 — level 30 is permanently named 'Warning: ' for the rest of the run.

$ pytest arc/scheduler_test.py -n0 -q
89 passed in 4.61s

$ pytest arc/reaction/reaction_test.py arc/scheduler_test.py -n0 -q
AssertionError: False is not true : no warning for an unstable TS: ['Warning: ', 'Warning: ']
5 failed, 115 passed in 16.18s

Makefile:56 runs -n auto, and xdist worker assignment isn't stable across runs, so this is a flaky red that won't reproduce in a scoped local run. grep -rn "levelname == 'WARNING'" arc/*_test.py matches only these five lines — the pattern is new to this PR.

This is also why the "zero failures" claim doesn't hold: full tree, single process, clean HEAD is 13 failed, 2974 passed, 10 skipped.

Fix: assert r.levelno == logging.WARNING (an immutable int).


3. Adoption is inert for the species it measures

arc/scheduler.py:3182 vs :3186 · confidence 9

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)   # 3186

The barrier is computed and E0-checked before the stability job is spawned. The verdict lands asynchronously, and check_stability_job never re-runs or re-checks the TS. So the only channel through which adoption changes a number is a TS switch — and finding 1 says that channel is defeated.

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.


Then

4. A DFT-level verdict flips the reference at every other level and ESS

arc/job/adapters/common.py:479 · confidence 8

The stability job runs at freq_level (scheduler.py:1789) — B3LYP-class DFT, notoriously prone to RHF→UHF instability. The published energy comes from sp_level, typically CCSD(T)-F12 in Molpro or DLPNO-CCSD(T) in Orca. The gate consults nothing but is_ts and reference-agnostic method types, and every adapter reads it: orca.py:310 ('r' if is_restricted else 'u'), molpro.py:233, qchem.py:225, terachem.py:275.

Neither Orca nor Molpro has symmetry-breaking machinery in ARC's templates (Orca needs %scf BrokenSym/FlipSpin), so the flag is likely a no-op there too, while changing the algorithm and losing closed-shell efficiency. Magnitude unquantified — would need real output from those ESSs.

5. The mixed-reference check is structurally blind in the most common configuration

arc/scheduler.py:1544-1569 · confidence 8

When sp_level == opt_level, run_sp_job calls post_sp_actions directly and check_sp_job is never reached. record_scf_reference is called only from check_sp_job and check_freq_job, so scf_references['sp'] is permanently absent, check_scf_reference_consistency returns at its first guard forever, and reference_mismatch is permanently null. A single-level campaign cannot report a mixed reference under any circumstances.

Fix: record the reference in post_sp_actions, from the opt job's memo.

6. invalidates_analytic_freq has no consumer

arc/parser/adapters/gaussian.py:295-301 · confidence 10 (call-site grep: only tests, docstrings, and arc/output.py:248)

An internal instability — which check_stability_job itself describes as putting the analytic frequencies "outside the range in which they are defined" — produces a logger.warning, and then the ZPE and imaginary frequency go to Arkane unchanged.

Asymmetry worth closing: the mixed-reference case writes MIXED_SCF_REFERENCE_MESSAGE into output[label]['warnings']; the invalid-Hessian case writes nothing there, so it never reaches output.yml's warnings field.

7. Spin contamination is measured and not acted on

confidence 10

arc/testing/stability/stable_spin_contaminated_doublet_ts.out
stability: verdict 'stable', restricted False, invalidates_analytic_freq False
s2       : {'s_squared': 1.7488, 's_squared_expected': 0.75, 's_squared_annihilated': 0.774}

A doublet TS at 133% contamination passes as stable and its E0 goes to Arkane. Nothing compares s_squared against s_squared_expected outside output.yml. In practice this condition is far more common than an RHF→UHF instability, and the diagnostic you've already built could gate on it with two lines in check_freq_job.

8. arc/checks/spin.py ships three API traps, and no consumers

confidence 10 for each measurement

  • s2_ls defaults to 0.0 (:52, :120) — silently wrong for every non-singlet target:
    BS doublet (S2=1.0) / HS quartet (3.80), target doublet:
      doublet target   : -100.00892857142857
      default (singlet): -100.03571428571428
      error kcal/mol   : -16.81
    triplet target: -50.0556 vs -50.3333  ->  17.4 kcal/mol
    
    s_squared_expected_from_multiplicity (arc/parser/parser.py:282) computes exactly this value and isn't reachable from the API that needs it. Drop the default; take the target multiplicity.
  • MIN_S2_SEPARATION is a cliff (:103-106):
    sep=0.0999 -> None       (logger.debug only)
    sep=0.1001 -> -109.99000999000998    # a -9.99 Ha "correction" from E_BS = -100.0
    sep=0.11   -> -109.09090909090908
    sep=1.0    -> -101.0
    
    Refusing below the threshold is right and 0.1 is a defensible floor. The problem is the discontinuity, and that the first value past the guard is nonsense returned without a warning. Cap the amplification ratio rather than the denominator, and warn rather than debug when refusing.
  • Nothing binds e_bs and e_hs to the same geometry, basis and functional (:48-53, :115-121); the returned record (:152-163) carries no level, geometry or path. The obvious future mistake — pulling E_HS from the triplet's own optimized geometry, which ARC has lying around — is silently wrong and unguarded. Take a Level and an xyz, and refuse mismatches.
  • Two boundary mutants survived the suite: :103 <<= and :150 >>= both leave pytest arc/checks/spin_test.py at 26 passed. test_a_separation_at_the_floor_is_projected uses 0.2, not 0.1.

9. Three docstrings teach the wrong error direction

arc/job/adapters/common.py:394-396, repeated at scheduler.py:1859-1862 and in check_stability_job · confidence 10

They state the true low-spin energy lies between E_BS and E_RKS. Your own function refutes it:

E_restricted -100.0   E_BS -100.01   E_HS -99.99
E_projected  -100.03000000000002
projected below BS? True
is truth between E_BS and E_R? False
BS-R kcal/mol -6.28    proj-BS kcal/mol -12.55

Yamaguchi AP with E_BS < E_HS always gives E_proj < E_BS — below both, here by twice the R→BS gap. Adoption is an under-correction in a monotone direction, which is a better story than the one currently told; but whoever wires spin.py up will size the residual error at half its magnitude with the wrong sign.

10. stable=(rext,noopt) on an unrestricted reference runs only the internal test

arc/parser/adapters/gaussian.py:299 · confidence 10

All four shipped fixtures contain exactly one Stability analysis using <AA,BB:AA,BB> singles matrix and zero <AB,BA blocks — including both UB3LYP ones. So for an unrestricted reference the external (UHF→GHF) test never runs, yet check_stability_job's docstring and invalidates_analytic_freq treat a 'stable' verdict on an unrestricted reference as covering "any instability, internal or external". The verdict is narrower than the record claims.

For the restricted case your claim is confirmed: one matrix carries both Singlet-A (internal) and Triplet-A (external) roots, and the fixture's Triplet-A at −0.0642 Ha produces RHF -> UHF.

11. Two records of the same run disagree after a TS switch

arc/scheduler.py:4425-4433 · confidence 8

delete_all_species_jobs resets output[label]['paths'] and ['job_types'], so arc/output.py:657 reports wavefunction_stability: null for the final TS. But output[label]['wavefunction_stability'] (set at :1916) and output[label]['info'] (:1917) are top-level keys nothing clears, so arc/main.py:757 prints the abandoned guess's verdict against the surviving geometry. carry_stability_verdict_across_ts_switch was written to stop exactly this on the species object, and misses the output record.

A related consequence: the carried verdict drops the log path, so output.yml can show scf_reference.source: derived with log: null.

12. Order dependency in switch_ts is untested

arc/scheduler.py ~:3401-3416 · confidence 10 (mutation survived)

Swapping the call order of carry_stability_verdict_across_ts_switch and determine_most_likely_ts_conformer leaves pytest arc/scheduler_test.py -n0 -q at 89 passed. test_switch_ts_reduces_the_stability_verdict mocks out determine_most_likely_ts_conformer entirely, so chosen_ts never changes and the ordering is invisible. A future refactor that swaps them would record measured_on_ts_guess against the new guess instead of the abandoned one, with nothing catching it.


Unresolved — two passes reached opposite conclusions

Does the once-per-species spawn guard survive a restart?

arc/scheduler.py:1785 · confidence 6 — read-only on both sides, nobody ran a restart

if not self.job_types.get('stability', False) or 'stability' in self.job_dict[label].keys():
    return
  • One reading: job_dict is rebuilt empty at Scheduler init (:467) and repopulated by restore_running_jobs only from restart_dict['running_jobs'] — a snapshot of running jobs. A completed stability job isn't in it, so after a restart the key is gone and a subsequent freq re-run spawns a duplicate.
  • The other: no re-fire path exists, because a freq job already completed in a prior session is never resubmitted, and post_freq_actions only fires on a freq-completion event.

The disagreement is over whether a freq re-run can occur after the stability job has already completed, across a restart boundary. Impact either way is duplicate HPC spend, not a wrong number.

To settle: exercise a restart with stability: true and a freq re-run, or add self.output[label]['job_types'].get('stability') to the guard — that flag is already persisted in restart_dict['output']. Note the interaction you'd be choosing: delete_all_species_jobs resets it on a TS switch, so an output-based guard re-enables one spawn per TS guess. That may be what you want, but it's a change from the documented "once per species".


Housekeeping

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_ls plumbing is complete — it reaches the formula, the ordering guard (:107) and broken_symmetry (:150).
  • Before-annihilation ⟨S²⟩ is right and consistently fed. s_squared: 0.7536 from the <Sx>= line vs s_squared_annihilated: 0.75. The Initial guess exclusion works (uhf_died_before_scf_septet.outNone, not 12.0) and the first-Multiplicity rule survives guess=fragment (→ expected 0.75, not the fragment's 1).
  • AEC/BAC is not a new problem. arc/statmech/arkane.py:373-398 applies 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-QB3 isn't a Gaussian keyword — CBS-QB3 selects its open-shell variant from the multiplicity itself, as do AM1/PM6 — and run_stability_job:1802-1806 refuses any non-DFT/HF level, so a derived verdict can't exist for a composite species anyway.
  • Your guess=mix re-derivation holds. arc/testing/restart/1_restart_thermo/calcs/freq_a19031.out: route line 84 #P guess=mix wb97xd/def2tzvp freq, line 105 Multiplicity = 1, line 223 Mixing orbitals, IMix= 1 ... Coef= 7.07106781D-01 7.07106781D-01, line 422 SCF 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.exe paths, 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 via output.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.yml constructed; the three new attributes defaulted to None/{}/None. No KeyError/TypeError/AttributeError.
  • No stability respawn on resumed old projectsdefault_job_types['stability'] is False and initialize_job_types fills the missing key from defaults_to_false.
  • The number_of_radicals provenance question is moot. Only two assignments exist in arc/: 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_eigenvectors are always lists.
  • ts_checks semantics untouched by this diff.
  • restricted_used round-trips safely — popped in restore_running_jobs before job_factory.

Contracts

  • ESSAdapter.parse_s_squared has a safe return None default (arc/parser/adapter.py:210), not @abstractmethod. All 9 subclasses probed; the 6 without an override inherit cleanly, no AttributeError.
  • No JobAdapter abstract method added, so no adapter is forced to implement anything new. job_type='stability' is only ever constructed by run_stability_job, which gates on job_adapter != 'gaussian'.
  • The default_job_types in-place-mutation trap does not bitearc/common.py:92 lists stability in defaults_to_false; zero import-time default_job_types[key] subscripts anywhere.
  • specific_job_type: stability runs nothing, confirmed by execution, matching your own admission — and it does not also hang, since check_all_done (:3843-3845) skips stability. 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=True in 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_job passes only xyz and level.
  • No path traversal — the only path written to output.yml is 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-parsecheck_stability_job is 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_stability and 1.51 s / 394 MB for parse_s_squared — the same readlines() pattern every existing ARC parser uses, and real logs are orders smaller.
  • Parser false positives swept: across all tracked .out/.log fixtures, only the four new stability fixtures parsed — parsed_count 4, errors_count 0.
  • stability cannot block convergence and is correctly reset by delete_all_species_jobs.

Mutants the suite killed — i.e. the tests work:

  • 'unknown''stable' on an unreadable verdict: killed by test_unparsed_verdict_is_not_reported_as_stable.
  • number_of_radicals is None → truthiness: killed, confirming declared-0 is distinguished from undeclared-None.
  • number_of_radicals > 1>= 1: killed, confirming a declared 1 isn't credited as an open-shell source.
  • Q-Chem len(tokens) >= 2> 2: killed by test_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.

@calvinp0
calvinp0 force-pushed the feature_wavefunction_stability_check branch 3 times, most recently from 33d20e6 to f9c0a3a Compare August 23, 2026 09:52
…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.
@calvinp0
calvinp0 force-pushed the feature_wavefunction_stability_check branch from f9c0a3a to afc47f9 Compare August 23, 2026 12:17
@calvinp0

Copy link
Copy Markdown
Member Author

Reviewed at 32f60644aaa535eae16b01636c99ccf0fe595d17 against merge-base d285b1ed004bf3b7b5140b9da11986236ea4a8b1 — 33 files, +7378/−29, 16 source files across 9 packages.

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:
Score Established by
10 Executed. Output pasted in.
8–9 Read the specific lines and traced the path.
6–7 Read and reasoned; nobody ran the failure.
≤5 Pattern match; check before acting.

Fix first

1. The adopted unrestricted reference collapses back to restricted — the feature is currently a no-op

arc/job/adapters/gaussian.py:405 · confidence 10 — code path executed, SCF consequence measured

guess=read is emitted whenever a checkfile exists and guess=mix otherwise, chosen with no reference to restricted/unrestricted. The u prefix is decided ~30 lines later. So a post-adoption job reads a restricted checkpoint into a UKS calculation.

Composed inputs, with an adopted verdict on a singlet TS:

sp    chk=True  adopted=True  restricted_used=False
        #P integral=(grid=ultrafine, Acc2E=12) guess=read ub3lyp/def2tzvp ...
opt   chk=True  adopted=True  restricted_used=False
        #P opt=(calcfc,maxcycle=100,noeigentest,ts)  guess=read ub3lyp/def2tzvp ...
sp    chk=False adopted=True  restricted_used=False
        #P integral=(grid=ultrafine, Acc2E=12) guess=mix ub3lyp/def2tzvp ...

The line is pre-existing at the merge-base — this PR doesn't introduce it, it collides with it:

$ git show d285b1ed0:arc/job/adapters/gaussian.py | grep -n "guess=read' if self.checkfile"
396:  input_dict['job_type_1'] += ' guess=read' if self.checkfile is not None and os.path.isfile(self.checkfile) \

And the collision is guaranteed on exactly the path that needs the other branch: run_stability_job:1812-1816 requires species.checkfile to exist and match the freq job's before a verdict is recorded at all. species.checkfile is set on the first opt/optfreq/composite (scheduler.py:1275), cleared only by ESS troubleshooting (:4314), and not cleared by switch_ts. So at every adoption a checkfile is present and guess=mix is unreachable.

You already state the mechanism, at scheduler.py:1747-1750:

without a checkfile the route would fall back to guess=mix, whose deliberately symmetry-broken SCF is a different wavefunction than the one under test

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 measurement

PySCF 2.14, B3LYP/def2-TZVP, grids level 4, conv_tol=1e-9, on the 15-atom C5H10 TS geometry taken from the Input orientation block of arc/testing/stability/rhf_uhf_instability_singlet_ts.out. 215 basis functions.

--- RKS reference ---
E(RKS)              = -196.490056185  converged=True

--- UKS seeded from the converged RKS density  [guess=read analogue] ---
E(UKS | from RKS)   = -196.490056186  converged=True  <S^2>=-0.000000

--- UKS from a HOMO/LUMO-mixed start  [guess=mix analogue] ---
E(UKS | from mix)   = -196.510081567  converged=True  <S^2>=0.863364

================ VERDICT ================
E(UKS from RKS) - E(RKS) = -4.724e-10 Ha  (-0.0000 kcal/mol)
E(UKS from mix) - E(RKS) = -2.003e-02 Ha  (-12.5661 kcal/mol)
UKS-from-RKS collapsed back to the restricted solution : True
UKS-from-mix found a lower, broken-symmetry solution   : True
energy the adoption path leaves on the table           : -12.5661 kcal/mol

Three things this establishes:

1. **The setup is your fixture's.** E(RKS) = −196.490056185 Ha against the fixture's own `SCF Done: E(RB3LYP) = -196.490056259` (line 523) — agreement to 7.4e-8 Ha across two different programs and grids.

2. **A UKS calculation seeded from a converged restricted density returns the restricted energy exactly** — 4.7e-10 Ha, ⟨S²⟩ = 0.000000. It doesn't resemble the restricted solution; it _is_ the restricted solution, carrying an unrestricted label.

3. **A symmetry-broken start finds a genuine lower solution** — 12.57 kcal/mol below, ⟨S²⟩ = 0.863. The RKS reference at this geometry really is externally unstable, exactly as the fixture's Gaussian verdict (Triplet-A at −0.0642 Ha) says.

The internal/external split agrees across both programs: PySCF's RKS.stability() returns (mo_i, mo_e, stable_i, stable_e) = (ndarray, None, True, None) — internal stability True, matching your stable Singlet-A, with the external test simply not run by default (that slot is None, not False; reading it as a negative is a trap I hit on the first pass).

Consequence. On this fixture, the adoption path produces −196.4901 Ha and records scf_reference: unrestricted, reference_mismatch: false, when the broken-symmetry solution it was reaching for is at −196.5101 Ha. The barrier is wrong by 12.57 kcal/mol, in the direction the PR set out to correct, with every record saying the correction was applied.

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 guess=read analogue is a hand-built density seed, not Gaussian's checkpoint reader. A g16 run would close that last gap.

Reproduce it

import 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 direction

On adoption emit guess=mix (or guess=(read,mix)), or add stable=opt to the first post-adoption job. Then assert s_squared > BROKEN_SYMMETRY_S2_THRESHOLD on that job's output and warn or re-run if it collapsed — otherwise the same silent failure returns the moment the guess logic is touched again.

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 — _parse_spin_diagnostic (arc/output.py:334) reports s_squared: 0.0 against s_squared_expected: 0.0 for a multiplicity-1 species, i.e. perfectly healthy, for exactly this failure.

2. Five of the new tests are order-dependent and will flake CI red

arc/scheduler_test.py:737,768,781,796,809 · confidence 10

arc/common.py:186-189, inside initialize_log():

logging.addLevelName(logging.WARNING, 'Warning: ')   # process-global, permanent

The five new tests assert r.levelname == 'WARNING'. Once any earlier test in the same process constructs ARC(...) — e.g. arc/reaction/reaction_test.py:1054,1075 — level 30 is permanently named 'Warning: ' for the rest of the run.

$ pytest arc/scheduler_test.py -n0 -q
89 passed in 4.61s

$ pytest arc/reaction/reaction_test.py arc/scheduler_test.py -n0 -q
AssertionError: False is not true : no warning for an unstable TS: ['Warning: ', 'Warning: ']
5 failed, 115 passed in 16.18s

Makefile:56 runs -n auto, and xdist worker assignment isn't stable across runs, so this is a flaky red that won't reproduce in a scoped local run. grep -rn "levelname == 'WARNING'" arc/*_test.py matches only these five lines — the pattern is new to this PR.

This is also why the "zero failures" claim doesn't hold: full tree, single process, clean HEAD is 13 failed, 2974 passed, 10 skipped.

Fix: assert r.levelno == logging.WARNING (an immutable int).

3. Adoption is inert for the species it measures

arc/scheduler.py:3182 vs :3186 · confidence 9

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)   # 3186

The barrier is computed and E0-checked before the stability job is spawned. The verdict lands asynchronously, and check_stability_job never re-runs or re-checks the TS. So the only channel through which adoption changes a number is a TS switch — and finding 1 says that channel is defeated.

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.

Then

4. A DFT-level verdict flips the reference at every other level and ESS

arc/job/adapters/common.py:479 · confidence 8

The stability job runs at freq_level (scheduler.py:1789) — B3LYP-class DFT, notoriously prone to RHF→UHF instability. The published energy comes from sp_level, typically CCSD(T)-F12 in Molpro or DLPNO-CCSD(T) in Orca. The gate consults nothing but is_ts and reference-agnostic method types, and every adapter reads it: orca.py:310 ('r' if is_restricted else 'u'), molpro.py:233, qchem.py:225, terachem.py:275.

Neither Orca nor Molpro has symmetry-breaking machinery in ARC's templates (Orca needs %scf BrokenSym/FlipSpin), so the flag is likely a no-op there too, while changing the algorithm and losing closed-shell efficiency. Magnitude unquantified — would need real output from those ESSs.

5. The mixed-reference check is structurally blind in the most common configuration

arc/scheduler.py:1544-1569 · confidence 8

When sp_level == opt_level, run_sp_job calls post_sp_actions directly and check_sp_job is never reached. record_scf_reference is called only from check_sp_job and check_freq_job, so scf_references['sp'] is permanently absent, check_scf_reference_consistency returns at its first guard forever, and reference_mismatch is permanently null. A single-level campaign cannot report a mixed reference under any circumstances.

Fix: record the reference in post_sp_actions, from the opt job's memo.

6. invalidates_analytic_freq has no consumer

arc/parser/adapters/gaussian.py:295-301 · confidence 10 (call-site grep: only tests, docstrings, and arc/output.py:248)

An internal instability — which check_stability_job itself describes as putting the analytic frequencies "outside the range in which they are defined" — produces a logger.warning, and then the ZPE and imaginary frequency go to Arkane unchanged.

Asymmetry worth closing: the mixed-reference case writes MIXED_SCF_REFERENCE_MESSAGE into output[label]['warnings']; the invalid-Hessian case writes nothing there, so it never reaches output.yml's warnings field.

7. Spin contamination is measured and not acted on

confidence 10

arc/testing/stability/stable_spin_contaminated_doublet_ts.out
stability: verdict 'stable', restricted False, invalidates_analytic_freq False
s2       : {'s_squared': 1.7488, 's_squared_expected': 0.75, 's_squared_annihilated': 0.774}

A doublet TS at 133% contamination passes as stable and its E0 goes to Arkane. Nothing compares s_squared against s_squared_expected outside output.yml. In practice this condition is far more common than an RHF→UHF instability, and the diagnostic you've already built could gate on it with two lines in check_freq_job.

8. arc/checks/spin.py ships three API traps, and no consumers

confidence 10 for each measurement

* **`s2_ls` defaults to `0.0`** (`:52`, `:120`) — silently wrong for every non-singlet target:
  ```
  BS doublet (S2=1.0) / HS quartet (3.80), target doublet:
    doublet target   : -100.00892857142857
    default (singlet): -100.03571428571428
    error kcal/mol   : -16.81
  triplet target: -50.0556 vs -50.3333  ->  17.4 kcal/mol
  ```
  
  
      
        
      
  
        
      
  
      
    
  `s_squared_expected_from_multiplicity` (`arc/parser/parser.py:282`) computes exactly this value and isn't reachable from the API that needs it. Drop the default; take the target multiplicity.

* **`MIN_S2_SEPARATION` is a cliff** (`:103-106`):
  ```
  sep=0.0999 -> None       (logger.debug only)
  sep=0.1001 -> -109.99000999000998    # a -9.99 Ha "correction" from E_BS = -100.0
  sep=0.11   -> -109.09090909090908
  sep=1.0    -> -101.0
  ```
  
  
      
        
      
  
        
      
  
      
    
  Refusing below the threshold is right and 0.1 is a defensible floor. The problem is the discontinuity, and that the first value past the guard is nonsense returned without a warning. Cap the amplification ratio rather than the denominator, and warn rather than debug when refusing.

* **Nothing binds `e_bs` and `e_hs` to the same geometry, basis and functional** (`:48-53`, `:115-121`); the returned record (`:152-163`) carries no level, geometry or path. The obvious future mistake — pulling E_HS from the triplet's own optimized geometry, which ARC has lying around — is silently wrong and unguarded. Take a `Level` and an xyz, and refuse mismatches.

* **Two boundary mutants survived the suite**: `:103` `<`→`<=` and `:150` `>`→`>=` both leave `pytest arc/checks/spin_test.py` at `26 passed`. `test_a_separation_at_the_floor_is_projected` uses 0.2, not 0.1.

9. Three docstrings teach the wrong error direction

arc/job/adapters/common.py:394-396, repeated at scheduler.py:1859-1862 and in check_stability_job · confidence 10

They state the true low-spin energy lies between E_BS and E_RKS. Your own function refutes it:

E_restricted -100.0   E_BS -100.01   E_HS -99.99
E_projected  -100.03000000000002
projected below BS? True
is truth between E_BS and E_R? False
BS-R kcal/mol -6.28    proj-BS kcal/mol -12.55

Yamaguchi AP with E_BS < E_HS always gives E_proj < E_BS — below both, here by twice the R→BS gap. Adoption is an under-correction in a monotone direction, which is a better story than the one currently told; but whoever wires spin.py up will size the residual error at half its magnitude with the wrong sign.

10. stable=(rext,noopt) on an unrestricted reference runs only the internal test

arc/parser/adapters/gaussian.py:299 · confidence 10

All four shipped fixtures contain exactly one Stability analysis using <AA,BB:AA,BB> singles matrix and zero <AB,BA blocks — including both UB3LYP ones. So for an unrestricted reference the external (UHF→GHF) test never runs, yet check_stability_job's docstring and invalidates_analytic_freq treat a 'stable' verdict on an unrestricted reference as covering "any instability, internal or external". The verdict is narrower than the record claims.

For the restricted case your claim is confirmed: one matrix carries both Singlet-A (internal) and Triplet-A (external) roots, and the fixture's Triplet-A at −0.0642 Ha produces RHF -> UHF.

11. Two records of the same run disagree after a TS switch

arc/scheduler.py:4425-4433 · confidence 8

delete_all_species_jobs resets output[label]['paths'] and ['job_types'], so arc/output.py:657 reports wavefunction_stability: null for the final TS. But output[label]['wavefunction_stability'] (set at :1916) and output[label]['info'] (:1917) are top-level keys nothing clears, so arc/main.py:757 prints the abandoned guess's verdict against the surviving geometry. carry_stability_verdict_across_ts_switch was written to stop exactly this on the species object, and misses the output record.

A related consequence: the carried verdict drops the log path, so output.yml can show scf_reference.source: derived with log: null.

12. Order dependency in switch_ts is untested

arc/scheduler.py ~:3401-3416 · confidence 10 (mutation survived)

Swapping the call order of carry_stability_verdict_across_ts_switch and determine_most_likely_ts_conformer leaves pytest arc/scheduler_test.py -n0 -q at 89 passed. test_switch_ts_reduces_the_stability_verdict mocks out determine_most_likely_ts_conformer entirely, so chosen_ts never changes and the ordering is invisible. A future refactor that swaps them would record measured_on_ts_guess against the new guess instead of the abandoned one, with nothing catching it.

Unresolved — two passes reached opposite conclusions

Does the once-per-species spawn guard survive a restart?

arc/scheduler.py:1785 · confidence 6 — read-only on both sides, nobody ran a restart

if not self.job_types.get('stability', False) or 'stability' in self.job_dict[label].keys():
    return
* **One reading:** `job_dict` is rebuilt empty at Scheduler init (`:467`) and repopulated by `restore_running_jobs` only from `restart_dict['running_jobs']` — a snapshot of _running_ jobs. A completed stability job isn't in it, so after a restart the key is gone and a subsequent freq re-run spawns a duplicate.

* **The other:** no re-fire path exists, because a freq job already completed in a prior session is never resubmitted, and `post_freq_actions` only fires on a freq-completion event.

The disagreement is over whether a freq re-run can occur after the stability job has already completed, across a restart boundary. Impact either way is duplicate HPC spend, not a wrong number.

To settle: exercise a restart with stability: true and a freq re-run, or add self.output[label]['job_types'].get('stability') to the guard — that flag is already persisted in restart_dict['output']. Note the interaction you'd be choosing: delete_all_species_jobs resets it on a TS switch, so an output-based guard re-enables one spawn per TS guess. That may be what you want, but it's a change from the documented "once per species".

Housekeeping

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_ls` plumbing is complete — it reaches the formula, the ordering guard (`:107`) and `broken_symmetry` (`:150`).

* **Before-annihilation ⟨S²⟩ is right and consistently fed.** `s_squared: 0.7536` from the `<Sx>=` line vs `s_squared_annihilated: 0.75`. The `Initial guess` exclusion works (`uhf_died_before_scf_septet.out` → `None`, not 12.0) and the first-`Multiplicity` rule survives `guess=fragment` (→ expected 0.75, not the fragment's 1).

* **AEC/BAC is not a new problem.** `arc/statmech/arkane.py:373-398` applies 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-QB3` isn't a Gaussian keyword — `CBS-QB3` selects its open-shell variant from the multiplicity itself, as do AM1/PM6 — and `run_stability_job:1802-1806` refuses any non-DFT/HF level, so a derived verdict can't exist for a composite species anyway.

* **Your `guess=mix` re-derivation holds.** `arc/testing/restart/1_restart_thermo/calcs/freq_a19031.out`: route line 84 `#P guess=mix wb97xd/def2tzvp freq`, line 105 `Multiplicity = 1`, line 223 `Mixing orbitals, IMix= 1 ... Coef= 7.07106781D-01 7.07106781D-01`, line 422 `SCF 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.exe` paths, 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 via `output.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.yml` constructed; the three new attributes defaulted to `None`/`{}`/`None`. No `KeyError`/`TypeError`/`AttributeError`.

* **No stability respawn on resumed old projects** — `default_job_types['stability']` is `False` and `initialize_job_types` fills the missing key from `defaults_to_false`.

* **The `number_of_radicals` provenance question is moot.** Only two assignments exist in `arc/`: 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_eigenvectors` are always lists.

* **`ts_checks` semantics untouched** by this diff.

* **`restricted_used` round-trips safely** — popped in `restore_running_jobs` before `job_factory`.

Contracts

* **`ESSAdapter.parse_s_squared`** has a safe `return None` default (`arc/parser/adapter.py:210`), not `@abstractmethod`. All 9 subclasses probed; the 6 without an override inherit cleanly, no `AttributeError`.

* **No `JobAdapter` abstract method added**, so no adapter is forced to implement anything new. `job_type='stability'` is only ever constructed by `run_stability_job`, which gates on `job_adapter != 'gaussian'`.

* **The `default_job_types` in-place-mutation trap does not bite** — `arc/common.py:92` lists `stability` in `defaults_to_false`; zero import-time `default_job_types[key]` subscripts anywhere.

* **`specific_job_type: stability` runs nothing**, confirmed by execution, matching your own admission — and it does _not_ also hang, since `check_all_done` (`:3843-3845`) skips `stability`. 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=True`** in 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_job` passes only `xyz` and `level`.

* **No path traversal** — the only path written to `output.yml` is 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_job` is 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_stability` and 1.51 s / 394 MB for `parse_s_squared` — the same `readlines()` pattern every existing ARC parser uses, and real logs are orders smaller.

* **Parser false positives swept**: across all tracked `.out`/`.log` fixtures, only the four new stability fixtures parsed — `parsed_count 4, errors_count 0`.

* **`stability` cannot block convergence** and is correctly reset by `delete_all_species_jobs`.

Mutants the suite killed — i.e. the tests work:

* `'unknown'` → `'stable'` on an unreadable verdict: killed by `test_unparsed_verdict_is_not_reported_as_stable`.

* `number_of_radicals is None` → truthiness: killed, confirming declared-`0` is distinguished from undeclared-`None`.

* `number_of_radicals > 1` → `>= 1`: killed, confirming a declared `1` isn't credited as an open-shell source.

* Q-Chem `len(tokens) >= 2` → `> 2`: killed by `test_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.

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.

  • 2 - the order dependent tests. Note there were 7 lines. All seven now compare levelno, since initialize_log calls addLevelName(logging.WARNING, 'Warning: ').
  • 3 - Adoption is no longer inert. The analysis spawns from spawn_post_opt_jobs rather than post_freq_actions and freq/sp/IRC are held until the verdict lands. An unstable reference also means the geometry is wrong, so an adopted verdict re-optimises rather than only re-referencing - a Hessian at the restricted geometry on an unrestricted reference sits at a non-stationary point
  • Unresolved (restart guard) - resolved. The guard is now ARCSpecies.stability_analysis_ran, persisted through as_dict/from_dict

Fixed: 5, 6, 7, 8, 9, 10, 11, 12, H1, H2, H5, H6

Also, just for clarification:

  • 8 - spin.py's silent s2_ls = 0 default is gone; both entry points now require a multiplicity and derive <S**2>_LS fomr it. The 16.8kcal/mol figure for a BS-doublet/HS-quartet pair reproduced exactly. The MIN_S2_SEPARATION cliff is closed by capping amplification (<S**2>_BS - <S**2>_LS)/separation rather than the denominator - that ratio is w/(1-w) in the BS determinant's high spin weight, so an idel fully spin-flipped solution sits at exactly 1 and the cap admits up to w=2/3
  • 10 - fixed. The guassian reader no reports external_instability: None for an unrestricted reference matching ORCA. All four Gaussian fixtures hold exactly one <AA,BB:AA,BB> matrix and zero <AB,BA> blocks, including both UB3LYP ones, so Gaussian never tested that sector either. This changes exisitng Gaussian behaviour.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants