ferric

A quantum chemistry engine written in Rust, with Python bindings and a TOML-driven command line. It computes Hartree–Fock and DFT energies and gradients, the MP2 family, coupled cluster, RPA and GW, and constrained DFT, with libint2 for the integrals.

pip install ferric      # Linux x86_64, Python 3.10–3.13

Start here

Get started → Install the wheel and compute a checked number in under a minute. Then read the sharp bits that catch new users.

How-to guides → Task recipes: charged and open-shell molecules, optimization, SMILES input, QM/MM, ligand pipelines. Choosing a method maps a chemistry question to a method.

Methods → What each method family is, how to run it, how accurate it is, what it costs and what to cite.

Reference → Capabilities and validation · Input file · Python API · Examples · Rust API

If you areStart with
A chemist who wants numbersYour first calculation, then Choosing a method
Coming from PySCFFor PySCF users
Working on drug-discovery workflowsEnd-to-end applications and QM/MM
A method developerElectronic response, Architecture, Rust API
An automated agentFor automated agents

Implemented ≠ validated

Working code is not a checked number. These pages describe what exists; how far each capability's numbers have been checked against an independent reference differs a lot between methods. Each one is graded individually in Capabilities and validation. Read it before relying on a result.

The idea

ferric is organized around electronic response: how the density reacts to a perturbation. That object appears as the polarizability \( \alpha \), the dielectric function \( \varepsilon \) and the susceptibility \( \chi \), and three of the method families here are different ways of getting it right where standard methods get it wrong:

  • Attenuated MP2: MP2 builds dispersion from an uncoupled polarizability, which overbinds some systems (π-stacking is the classic case). Attenuating the correlation operator removes the long-range part where that error lives.
  • PDEP-RPA / GW: the dielectric matrix is the density–density response. PDEP keeps only its dominant eigenmodes, a low-rank representation of the screening used by RPA correlation and the GW interaction.
  • Constrained DFT: a constraint couples to the density and reads its response, building charge-localized diabatic states and their electron-transfer couplings.

Electronic response develops the argument and says which parts of it are demonstrated and which remain a design premise.

Source, license, citation

github.com/mgoldey/ferric, dual-licensed MIT / Apache-2.0. To cite ferric and the methods you used, see References and citing.

Installation

There are two ways in. Most people want the first.

You want toDo thisTime
Run calculationsInstall the prebuilt wheelabout a minute
Change ferric's Rust code, or use MPIBuild from sourceabout 10 minutes, mostly compiling ferric

Fastest: the prebuilt wheel

Wheels are published to PyPI for Linux x86_64 (manylinux_2_28), CPython 3.10–3.13. libint2 and libxc are statically linked into the extension, and OpenBLAS ships inside the wheel as a bundled shared library, so nothing needs compiling. The wheel's libint2 carries second derivatives, so analytic Hessians work from a plain pip install; see What the libint2 build carries.

pip install ferric        # or: uv pip install ferric

The only releases so far are pre-releases (0.1.0rc*). pip and uv both select them when no final release exists, so the plain command above works. Pin a version (ferric==0.1.0rc5) if you need a reproducible environment.

The wheel gives you two things:

  • the Python module: import ferric
  • a ferric command on PATH that runs TOML input files (the same CLI as cargo run --bin ferric)

Check it works

python -c "import ferric; print(ferric.__file__)"

Then run your first calculation. It takes under a second.

What the wheel does not contain

The wheel holds the compiled library and nothing else. The examples/ input files, the testdata/ molecules and the tools/ pipeline scripts live in the git repository. Pages that use them say so. To have them locally:

git clone https://github.com/mgoldey/ferric
cd ferric
ferric examples/water-rhf.toml

The CLI resolves [molecule] xyz = "..." relative to the current directory, so run example files from the repository root.

Building from source

Build from source if you are changing ferric, need the MPI build, or are on a platform without a wheel. ferric links libint2, a C++ integral library; scripts/install-libint.sh installs a prebuilt copy in seconds.

Prerequisites

  • Rust 1.75+: install via rustup
  • libint2 2.13.1: the conda-forge Linux x86-64 build, which includes second-derivative integrals; scripts/install-libint.sh downloads it (checksum pinned) and needs curl, python3, zstd and patchelf
  • libxc, OpenBLAS, LAPACK, Eigen3 and Boost headers
  • Python 3.10+ and maturin: optional, for the Python bindings

Steps (Ubuntu 22.04+)

The apt list matches what CI installs (.github/workflows/ci.yml).

# 1. System dependencies
sudo apt-get install -y build-essential cmake g++ gfortran wget git \
    libeigen3-dev libopenblas-dev liblapack-dev pkg-config \
    libxc-dev libboost-dev patchelf zstd \
    python3-dev python3-pip python3-venv

# 2. Get ferric and install libint2 (seconds)
git clone https://github.com/mgoldey/ferric
cd ferric
scripts/install-libint.sh ~/.local/libint2-2.13.1
export LIBINT2_PREFIX=~/.local/libint2-2.13.1

# 3. Build ferric
cargo build --release

# 4. Check it: seconds, not minutes
OPENBLAS_NUM_THREADS=1 ./target/release/ferric examples/water-rhf.toml

The last line should end with converged = true and energy = -74.9631468000 Hartree, as shown in the first calculation.

LIBINT2_PREFIX is read by crates/ferric-integrals/build.rs; unset, it defaults to the installer's ~/.local/libint2-2.13.1 when that exists, and to ~/.local otherwise, so the export above is optional for the default path. The install script records the library's absolute path in every binary that links it, so moving or deleting that directory breaks the build's executables until they are rebuilt. A prefix holding a static libint2.a (a from-source libint2 build) also works, but a library generated without second derivatives has no analytic Hessians; frequencies then use finite differences of the analytic gradient.

The workspace has three binaries (ferric, ferric-cli and ferric-batch), so cargo run needs to be told which one: cargo run --release --bin ferric -- input.toml.

Running the test suite

OPENBLAS_NUM_THREADS=1 cargo test --workspace

.cargo/config.toml sets OPENBLAS_NUM_THREADS=1 for cargo-invoked processes only when the variable is unset (its [env] entry has no force = true, so an exported value wins). The prefix above makes this command use 1 regardless of your shell. Do not raise it above 1; see Threading. The Python binding tests are pytest, not cargo; see CONTRIBUTING.md.

Debug vs release

  • Debug (cargo build): fast to compile, slow to run. Use it while iterating on Rust code. It catches debug_assert! violations and integer overflow that release builds silently allow.
  • Release (cargo build --release): slow to compile, fast to run. Use it for anything you will actually wait on: real molecules, benchmarks, and any RPA/GW/CC job.

Python bindings from source

uv sync
uv run maturin develop --release
uv run python -c "import ferric; print(ferric.__file__)"

Use uv run maturin develop, not a bare maturin develop. A bare one can install into a different interpreter from the one uv run python loads, and the stale build keeps getting imported.

What the libint2 build carries

Integral classes, derivative orders and angular-momentum limits are fixed when libint2's source is generated, so no build flag changes them. Highest angular momentum for energy / 1st / 2nd derivatives (– = not generated):

IntegralsSource build with scripts/install-libint.sh (conda-forge 2.13.1)PyPI wheel (ferric's libint2 2.7.2 export)Needed for
4-centre ERI7 / 6 / 36 / 6 / 3SCF, gradients, analytic Hessians
One-electron (overlap, kinetic, nuclear)7 / 6 / 36 / 4 / 3the same
3- and 2-centre ERI7 / 7 / 46 / 6 / –RI-MP2, RPA, GW and their gradients
G12 geminal4 / – / ––F12 / geminal integrals

With either build, analytic Hessians cover orbital bases up to f functions; a basis with g or higher functions uses finite differences of the analytic gradient. The wheel has no G12 class, so F12 methods need a source build. scripts/generate-libint-small.sh regenerates the wheel's export. The upstream mpqc4 tarball (libint2 2.7.2) has no second derivatives and no G12 class: built against it, ferric uses finite-difference Hessians and the G12 tests skip with an explicit message.

MPI

MPI is a source build only. There is no MPI wheel on PyPI: the wheels workflow deliberately does not publish one.

sudo apt-get install -y libopenmpi-dev openmpi-bin libclang-dev
cargo build --release --workspace --features mpi
mpirun -np 4 -x OPENBLAS_NUM_THREADS=1 ./target/release/ferric examples/water-rhf.toml

libclang-dev is needed by mpi-sys's bindgen step. MPICH and Intel MPI are not supported: their libmpi.so.12 ABI differs from OpenMPI 4.x's libmpi.so.40.

The CLI is the MPI entry point; the Python API is not

The CLI is SPMD by design: mpirun -np N ferric input.toml runs one rank per process.

mpirun -np N python script.py is not supported. The bindings expose no rank or world-size accessor, so if rank == 0 cannot be written. Every rank runs the whole script, prints N times, and races on the same output files.

Threading

The ferric binary and import ferric pin OpenBLAS to one thread when OPENBLAS_NUM_THREADS is unset, and honour the variable when it is set (cargo sets it to 1 through .cargo/config.toml, but only when it is unset, so an exported value reaches cargo-launched processes). Leave it unset or at 1. ferric uses rayon for its parallelism; a threaded OpenBLAS on top of that oversubscribes the machine, and its LU routines can crash when called from rayon workers. For throughput across many independent jobs, prefer many single-threaded processes over one multi-threaded job.

Your first calculation

This page takes you from an installed ferric to one checked number, then shows where to go next. It assumes you have run pip install ferric (Installation). No git clone is needed until the last section.

We compute the Hartree–Fock energy of water in the minimal STO-3G basis. It takes well under a second and has a known answer, so you can tell immediately whether your installation is right.

1. From Python

Save this as water.py:

import ferric

# XYZ format: atom count, a comment line, then symbol x y z in Ångström.
water = ferric.Molecule.from_xyz_string("""3
water
O   0.000000   0.000000   0.117790
H   0.000000   0.755453  -0.471161
H   0.000000  -0.755453  -0.471161
""", 0, 1)                       # charge 0, spin multiplicity 1 (singlet)

basis = ferric.BasisSet.bundled("sto-3g")
rhf = ferric.run_rhf(water, basis)

print(rhf.converged, f"{rhf.energy:.10f}")

Run it:

OPENBLAS_NUM_THREADS=1 python water.py

You should see:

True -74.9631468000

Two things to notice:

  • Always read converged. When run_rhf runs out of iterations it still returns an energy; it sets the flag and does not raise. A number with converged == False is not a result. See Sharp bits.
  • Energies are in Hartree. Geometries go in as Ångström; see Sharp bits for which accessors return Bohr.

2. The same thing from the command line

The wheel also installs a ferric command that reads a TOML input file. Write the geometry to water.xyz:

printf '3\nwater\nO 0.000000 0.000000 0.117790\nH 0.000000 0.755453 -0.471161\nH 0.000000 -0.755453 -0.471161\n' > water.xyz

and write water-rhf.toml next to it:

[molecule]
xyz = "water.xyz"      # resolved relative to the directory you run ferric from

[basis]
name = "sto-3g"

[method]
kind = "rhf"
OPENBLAS_NUM_THREADS=1 ferric water-rhf.toml

The output ends with:

RHF/sto-3g on water.xyz
  nbasis     = 7
  iterations = 8
  converged  = true
  energy     = -74.9631468000 Hartree

Same molecule, same number. The CLI rejects any key it does not recognise, so a typo is an error rather than a silently ignored setting. Every key is listed in the input reference.

3. Add correlation

To go beyond Hartree–Fock, change the method and the basis. RI-MP2 needs an auxiliary (fitting) basis alongside the orbital basis, and STO-3G is too small for a meaningful correlation energy, so this example moves to cc-pVDZ:

bs  = ferric.BasisSet.bundled("cc-pvdz")
aux = ferric.BasisSet.bundled("cc-pvdz-ri")
mp2 = ferric.run_rimp2(water, bs, aux)
print(f"RI-MP2 total energy: {mp2.total_energy:.10f} Ha")
RI-MP2 total energy: -76.2308014541 Ha

In TOML the same calculation changes the [basis] name and the method kind, and adds an [mp2] section naming the auxiliary basis:

[basis]
name = "cc-pvdz"

[method]
kind = "rimp2"

[mp2]
auxbasis = "cc-pvdz-ri"

If auxbasis is omitted the CLI uses cc-pvdz-ri, but name it explicitly: the auxiliary basis should match the orbital basis. This is examples/water-rimp2.toml.

4. Where to next

You want toGo to
Pick a method for a chemistry questionChoosing a method
See what every method supports (open shell? gradients? CLI?)Capabilities and validation
Charged or open-shell molecules, geometry optimization, SMILES inputRecipes
The whole Python surfacePython bindings
Coming from PySCFFor PySCF users
Know what to trustCapabilities and validation

Running the bundled examples

The repository has one input file per method under examples/, indexed in Examples. They refer to molecules under testdata/, which the wheel does not include, so run them from a clone:

git clone https://github.com/mgoldey/ferric && cd ferric
OPENBLAS_NUM_THREADS=1 ferric examples/water-rimp2.toml
OPENBLAS_NUM_THREADS=1 ferric examples/water-attmp2.toml   # attenuated MP2, ω = 0.420 Å⁻¹

If you built from source rather than installing the wheel, replace ferric with cargo run --release --bin ferric --.

Sharp bits

Things that behave differently from what you might expect, each of which has cost someone a run. This page follows the pattern of JAX's "Sharp Bits": each entry says what surprises people, why ferric does it that way, and what to do.

Leave OPENBLAS_NUM_THREADS unset

What happens: the ferric CLI and import ferric pin OpenBLAS to one thread when OPENBLAS_NUM_THREADS is unset, and honour the variable when it is set. (.cargo/config.toml sets it to 1 for cargo-launched processes, also only when it is unset.) A value above 1 is therefore a deliberate choice, and its effect depends on the workload: it can speed up one calculation and slow another down several-fold.

Why: ferric's parallelism comes from rayon. OpenBLAS threads run inside rayon's worker threads, so the thread count is roughly BLAS threads × rayon threads. Measured on benzene with the ferric CLI, single runs on a heavily loaded machine with 6 physical cores (12 hardware threads), so read these as order-of-magnitude:

OPENBLAS_NUM_THREADSunset1612
RI-JK RHF (benzene-rhf-def2-rijk.toml)54 s31 s9.7 stimed out at 1800 s
PDEP-RPA (benzene-pdep-rpa.toml)5.9 s7.2 s22.9 s30.7 s

"unset" and 1 run the same configuration, so the gap between those two columns is the machine's load, not a setting. B3LYP timings were noisy, with no clear trend. None of these runs crashed.

Three effects matter beyond speed:

  • Oversubscription can stall a run. Once BLAS threads × rayon threads exceed the physical cores, a job can slow by orders of magnitude, as the 12-thread RI-JK run shows.
  • Results change slightly. The BLAS thread count changes the order of floating-point summation. The RI-JK energy at 6 threads differs from the 1-thread energy by 6e-6 Ha; expect differences at the 1e-6 to 1e-5 Ha level.
  • LU routines can crash. Threaded OpenBLAS LU factorization (dgetrf) can overflow the 2 MB stacks of rayon's worker threads. ferric calls LU solves in BSE, PCM, COSMO, MBD and orbital-rotation steps, among others.

Do: leave OPENBLAS_NUM_THREADS unset, and ferric runs with one BLAS thread. If you set it higher for a particular job, never above the number of physical cores, and check the timing and the energy against a one-thread run of the same input. For throughput across many molecules, run many single-threaded processes.

converged is a flag, and it does not mean "ground state"

What happens: run_rhf, run_uhf and run_rohf return a result object even when they hit max_iter; they do not raise. (run_dft, run_ksdft and the correlated drivers behave differently: they raise if their reference SCF does not converge.)

Why: a non-converged state is still useful for diagnosis, and raising would discard it.

Do: check result.converged in Python, or converged = true in CLI output, before using any number, and treat False as "no result". True means the SCF reached a stationary point, not necessarily the lowest one; open-shell molecules especially can land on an excited solution. Comparing UHF with ROHF, or a different starting guess, is a cheap check. See Python bindings.

energy_conv is a sanity bound, not a target

What happens: setting [scf] energy_conv very tight (say 1e-10) can make a KS-DFT calculation never converge.

Why: convergence is driven by the density criterion (density_conv). The energy change between iterations has a noise floor set by the integration grid and density fitting, and a tight energy bound can sit below it. The CLI default is deliberately loose (1e-3).

Do: tighten density_conv, not energy_conv.

Density fitting is on in run_dft but off in run_rhf

What happens: comparing a ferric DFT energy with a code that uses exact Coulomb gives a discrepancy that grows with molecule size.

Why: run_dft, run_ksdft and the CLI's ksdft density-fit Coulomb (RI-J) with def2-universal-jkfit by default, and, for functionals with exact exchange (hybrids and range-separated hybrids), exchange (RI-K) with the same basis. run_rhf builds exact four-centre J and K unless you pass df_j_aux/df_k_aux. The difference is the fitting error that density fitting always carries, not a bug. Against exact J at PBE/STO-3G it is 0.28 kcal/mol for water, 1.16 for benzene and 9.5 for a 71-atom drug molecule.

Do:

  • To compare with an exact-Coulomb code (ORCA with NORI, PySCF without density_fit()), pass df_j_aux="exact" to run_dft or run_ksdft, and df_k_aux="exact" too for a hybrid. "", "none", "off" and "conventional" mean the same. In the CLI, set [scf] df_j_aux = "" and df_k_aux = "". The CLI's SCF J/K log line then reads RI-JK via with a blank name; the run uses exact J and K.
  • Or fit on both sides: density_fit(auxbasis="def2-universal-jkfit") in PySCF, or run_rhf(..., df_j_aux="def2-universal-jkfit", df_k_aux="def2-universal-jkfit") in ferric, so J and K are both fitted on both sides. run_rhf takes a basis name or "" here, not "exact".
  • run_qmmm (KS methods), run_gw and run_u_gw with xc, run_tddft with a functional, run_tdhf_static_polarizability, run_double_hybrid and run_rs_mp2_rpa always density-fit their reference SCF with def2-universal-jkfit and have no opt-out.

Units differ by interface and by accessor

QuantityWhereUnit
Molecule geometry input (from_xyz, from_xyz_string, .xyz files)Python, CLIÅngström
Coordinates inside the Rust libraryRustBohr
Attenuation ω: [mp2] omega (att-rimp2, rs-mp2-rpa), [mp2] mp2v_omega (mp2-v); omega= of run_attenuated_rimp2, run_rs_mp2_rpa, run_mp2_v, and terf_omega= of run_rs_mp2_rpaCLI TOML, PythonÅ⁻¹
Double-hybrid ω: [dft] omega (wb97x-l-v)CLI TOMLBohr⁻¹
tune_omega (bracket and result); omega= of compute_eri3_mo and compute_metric_2cPythonBohr⁻¹
Range-separation ωRust configsBohr⁻¹
QmmmSystem(...) coordinatesPythonÅngström
QmmmSystem.point_charges()PythonBohr
QmmmSystem.link_atom_positions()PythonÅngström
EnergieseverywhereHartree

Do: check the docstring of any coordinate accessor before converting it. Converting both QM/MM accessors "to be safe" makes one of them wrong by a factor of 1.89.

Energies take a BasisSet; geometry changes take a basis name

What happens: run_rhf(mol, ferric.BasisSet.bundled("sto-3g")) works, but run_optimize wants the string "sto-3g".

Why: entry points that move atoms (optimization, frequencies, saddle search) build the basis themselves for each geometry, so they take its name.

Do: check the signature in Python bindings.

Not everything is on the CLI

The CLI runs the method.kinds in the capability matrix, with task = energy, optimize or frequencies. It also has [qmmm], [cosmo], [pcm], [external_potential], [scf] stability_descent, [mp2] att_operator and [dft] grid_prune. These capabilities are Python-only:

CapabilityPythonWhat the CLI has instead
Transition-state search, IRCrun_saddle, run_ircno task for either
QM/MM MM forces, full-system gradient and optimization, smeared charges, Thole polarization, an MM force fieldrun_qmmm, run_optimize_qmmm, QmmmSystem, MmTopology[qmmm] embeds the QM region in fixed point charges from a PQR file, with link atoms and boundary schemes
Constrained DFT and electron-transfer couplingsrun_cdft, CdftConstraint, cdft_couplingnothing

Examples need the repository

examples/*.toml point at molecules under testdata/, and the pipeline tools live under tools/. Neither ships in the wheel. Clone the repository and run from its root; see Installation.

[memory] budget_gb does not cap the whole process

What happens: a job's resident memory can exceed budget_gb, and a job whose budget is too small usually still runs, slower, rather than failing. Benzene PBE/def2-SVP with budget_gb = 0.002 finishes with the same SCF iterations, a peak RSS of 157 MB, and 11.2 s of wall time instead of 3.5 s.

Why: the CLI turns budget_gb (or, when unset, 0.8 × available RAM) into one process-wide ledger. The large, size-dependent allocations reserve their bytes from it before allocating: three-index RI tensors, the DFT grid's AO cache, MO-transformed RI blocks, and the large tensors of the MP2, RPA, GW and CC methods. Two such allocations alive at the same time therefore cannot each claim the whole budget. Nothing else is charged: basis-sized matrices (Fock, density, DIIS history), integral engines, BLAS and per-thread scratch, allocator overhead. The spill path's two scratch blocks are reported but not refused, and together they can reach about twice the budget.

When a charged allocation does not fit, the result depends on the allocation:

  • The SCF's three-index tensor is spilled to a file in $TMPDIR (/tmp by default) and reread on every iteration.
  • The DFT grid AO cache is recomputed at every Fock build instead of stored. The energy is bit-identical.
  • An allocation with no fallback stops the job with an error naming it. For some methods this check comes after the SCF. Benzene RI-MP2/def2-SVP with budget_gb = 0.001 completes the SCF, then exits with RI-MP2 MO-side blocks ... requires 0.01 GB; budget is 0.00 GB.

Some stages print a warning when resident memory passes 110% of the budget. The warning never stops the run.

From Python, memory_budget_gb= sets the same per-allocation limits, but no shared ledger is installed. Each check compares its own allocation with the whole budget, not with what the other allocations have left. Two checks instead subtract the process's current RSS and allow 90% of the remainder: the KS-DFT grid AO cache (store or recompute) and the UKS Newton/TRAH fxc kernel's second grid cache. The fxc check subtracts RSS in the CLI too.

Do: leave room below the machine's real limit for the uncharged part. On a shared machine, also cap the process externally so a runaway job dies in its own cgroup, for example with scripts/ferric-limited -- ferric input.toml (defaults MemoryMax=12G, MemoryHigh=10G, no swap) or systemd-run --user --scope -p MemoryMax=12G -- ferric input.toml.

Implemented is not validated

A method listed on these pages exists and runs. How closely its numbers have been checked against an independent reference differs a lot from method to method. Look up the grade in Capabilities and validation before relying on a number.

ferric recipes

Copy-paste workflows that end in a number. Each gives its expected output and rough runtime, so you can tell "still running" from "silently wrong". That confusion is the most common failure when you drive a QC code you haven't used before.

Recipes 0, 1, 3 and 4 were executed as written for this page, on 2026-09-23. Recipe 2's output is quoted from an earlier run. Recipe 5 is a sketch, marked as such where it appears. Numbers labelled MEASURED came out of the program.

Prerequisite: a working ferric. The fast path is the prebuilt wheel (about a minute). A source build takes ~30 minutes, most of it libint2:

pip install ferric

See Installation. Build from source only if you are changing ferric itself or need MPI.

Which recipes need a git clone. The wheel contains the compiled library and the ferric command, nothing else. examples/, testdata/, scripts/ and the tools/ Python package live in the repository (what the wheel does not contain). Run those recipes from the repository root:

RecipeNeeds a clone?Extra dependencies
0. SMILES → energyyes (tools.structure)RDKit
1. Single pointonly for examples/water-rhf.toml; your own .xyz + TOML works anywhere—
2. Ions and radicalsno—
3. Optimizeonly for examples/h2-lda-opt.toml—
4. Ligand funnelyes (tools.pipeline)RDKit, xtb on PATH
5. Residue rankingyes (tools.active_site)pdb2pqr

On a shared machine, run anything real under a memory cap. ferric's memory budget charges only the large, size-dependent tensors; basis-sized matrices, integral engines, BLAS scratch and allocator overhead are not charged, so a job's resident memory can exceed the budget. Only a cgroup puts a hard ceiling on the process, and without one an overshoot can trigger the system-wide OOM killer, which may kill unrelated processes. The details are in Sharp bits and For agents. scripts/ferric-limited (repository, Linux with systemd) wraps a command in a systemd-run --user scope:

scripts/ferric-limited --max=8G --high=7G -- ferric input.toml

0. From a SMILES to an energy

import ferric
from tools.structure import from_smiles        # needs a clone + RDKit

mol = from_smiles("CCO")                        # ethanol
res = ferric.run_dft(mol, ferric.BasisSet.bundled("def2-svp"),
                     functional="PBE", dispersion="d3bj")
assert res.converged                            # always check this first
print(res.total_energy, res.e_scf, res.e_dispersion)

MEASURED (2026-09-23, 4 threads on a loaded machine, about 4 s):

-154.72507297  -154.72071374  -0.00435923
  • from_smiles returns an RDKit ETKDG embedding plus an MMFF cleanup, seeded so it is reproducible. That is a starting structure, not a minimum. For anything you will report, relax it (xtb, then a ferric optimization) first. See recipe 3 and Golden paths Step 1b.
  • total_energy is e_scf + e_dispersion. dispersion=None (the default) leaves e_dispersion as None, meaning "not evaluated", never 0.0.
  • run_dft density-fits the Coulomb term by default. That matters when you compare against a code using exact Coulomb (recipe 6).
  • from_smiles reads the charge from the SMILES. Spin is never inferred: pass multiplicity=2 for a radical.

For a file instead of a SMILES, tools.structure.read("x.sdf") returns the same kind of Molecule (PDB, mmCIF, PQR, SDF, mol2, .gro and XYZ are supported). A PDB must already carry explicit hydrogens.


1. Single-point energy from a TOML file

The first success that confirms your install works. From the repository root:

ferric examples/water-rhf.toml

MEASURED (about 2 s including start-up):

RHF/sto-3g on testdata/molecules/water.xyz
  nbasis     = 7
  iterations = 9
  converged  = true
  energy     = -74.9631468000 Hartree

From a source checkout, the same run is cargo run --release --bin ferric -- examples/water-rhf.toml.

For your own molecule you need an .xyz (Å) and a TOML file:

[molecule]
xyz = "mymol.xyz"        # relative to the directory you run ferric from

[basis]
name = "def2-svp"

[method]
kind = "rhf"

For every method.kind, see Capabilities and validation. For every TOML key, see the input reference.


2. Charged and open-shell species

Read this before you run any ion, radical or metal center. charge and multiplicity are [molecule] keys. Most files in examples/ leave them at their defaults (0 and 1). The open-shell exceptions are examples/h_uhf.toml and examples/oh-ugw.toml.

[molecule]
xyz = "tBu.xyz"
charge = 1          # cation
multiplicity = 1    # closed-shell singlet

[basis]
name = "def2-svp"

[method]
kind = "ksdft"

[dft]
functional = "B3LYP"

Relax the geometry with xtb first. A correct formula doesn't guarantee a physical structure, and the parity check below can't tell the difference. MEASURED: a hand-built C10H17+ with a 0.902 Å C–H contact stalled the SCF. After xtb mol.xyz --opt --chrg 1 --uhf 0 --gfn 2 it converged, 545 kcal/mol lower. See Golden paths Step 1b.

MEASURED on the tert-butyl cation C4H9+ (13 atoms), B3LYP/def2-SVP:

converged  = true
energy     = -157.4301362681 Hartree

Expect seconds to about a minute at this size.

The error you will hit first

If the electron count and the multiplicity disagree, you get this error. It's worth learning to recognise:

error: inconsistent charge/multiplicity: 35 electrons with multiplicity 1
implies n_alpha = (35 + 1 - 1) / 2 = 35/2, which is not a non-negative
integer... An odd electron count needs an even multiplicity (2, 4, ...)
and vice versa

It means your geometry, your charge or your multiplicity is wrong. ferric can handle ions and open shells. The message prints the arithmetic and the rule, so check the atom count in your .xyz against the first line of the file, then the charge and multiplicity you set.

A radical (open-shell doublet) uses the same keys:

[molecule]
xyz = "radical.xyz"
charge = 0
multiplicity = 2    # one unpaired electron -> UHF/UKS

From Python, multiplicity belongs to the molecule, not to the run_* call: ferric.Molecule.from_xyz("x.xyz", charge=0, multiplicity=2).


3. Optimize a geometry

ferric examples/h2-lda-opt.toml

MEASURED (about 2 s): converged = true, steps = 2, final E = -1.1212649781 Hartree.

The pattern is task = "optimize" next to any kind that has analytic gradients:

[method]
kind = "ksdft"
task = "optimize"

[dft]
functional = "B3LYP"

[optimize]
max_steps = 30

Capabilities and validation lists which methods have analytic gradients. Harmonic frequencies use task = "frequencies" (finite differences of the analytic gradient; see examples/water-frequencies.toml).

Runtime depends on the system, so measure before you plan. A whole optimization at drug-like size has not been timed on this page. For scale, two single-point measurements:

  • 32 atoms, PBE/def2-SVP: 96 s (the figure recorded in the tools.pipeline.tiers.tier4_dft docstring).
  • A 27-atom delocalised cation, PBE/6-31G: 4.9 min, because it needed 173 SCF iterations. At B3LYP/def2-SVP the same molecule was killed for memory after 22 min (Golden paths, Step 2).

An optimization multiplies the single-point cost by the number of steps. Measure one species before you queue many.


4. Ligand screening: force field → xtb → DFT

tools.pipeline.run_funnel runs a tiered funnel: cheap scoring on many candidates, and expensive QM only on the few that survive. Use it when you have N molecules and want DFT numbers on the good ones. Don't write your own loop.

This was run exactly as shown, from the repository root:

from tools.campaign.hierarchy import Tier
from tools.isomers.model import Isomer
from tools.pipeline import Stage, run_funnel
from tools.pipeline.tiers import tier2_forcefield, tier3_gfn2, tier4_dft

# Three isomers of C3H6O2, so comparing absolute energies is meaningful.
smiles = ["CCC(=O)O", "COC(C)=O", "CCOC=O"]
candidates = [
    Isomer(smiles=s, kind="structural", transform=s, parent_smiles=smiles[0])
    for s in smiles
]
stages = [
    Stage(Tier.FORCE_FIELD,   tier2_forcefield, keep=3, name="ff"),
    Stage(Tier.SEMIEMPIRICAL, tier3_gfn2,       keep=2, name="xtb"),
    Stage(Tier.QUANTUM,       tier4_dft,        keep=1, name="dft"),
]
rep = run_funnel(candidates, stages, {"seed": 0xF00D, "basis": "sto-3g"})
print(rep.table())
for iso in rep.survivors:
    print(iso.canonical, rep.value("dft", iso.canonical))

MEASURED (2026-09-23, one process):

tier  stage           in   out  failed     secs   s/cand  note
   2  ff               3     3       0      0.4     0.13  ff: kept 3 of 3 scored
   3  xtb              3     2       0      0.1     0.04  xtb: kept 2 of 3 scored
   4  dft              2     1       0      4.8     2.38  dft: kept 1 of 2 scored
      TOTAL                                 5.3
      dominant tier 4 (dft) = 90% of wall
COC(C)=O -264.5628932123858

STO-3G is a smoke-test basis, so don't read chemistry into which isomer survived. The run shows the plumbing works. Change "basis" in the context for real work.

What the funnel does that a hand-written loop usually doesn't:

  • Ranks ascending at every tier. Lower is better, because every tier reports an energy or an energy-like score. For the same reason, rank only candidates that share a molecular formula. An absolute energy of a larger molecule is lower because it has more electrons, not because it is better. For substituent series, rank against the parent (parent_smiles). The pipeline notes §0b cover that.
  • Drops failures instead of ranking them. A candidate that a tier failed on is counted as failed. It is never treated as having scored well. That's easy to get wrong by hand, and it silently corrupts a screen.
  • Times each tier. The tier that actually costs the run is often not the one the cost table predicts.
  • Stops early when the population is empty, instead of running an expensive tier on nothing.

tier4_dft adds D3(BJ) dispersion by default. Pass context["dispersion"] = None for the bare SCF energy. Docking (tier1_dock) needs the ferric[docking] extra, a receptor (context["receptor_pdbqt"]) and a box centre (context["box_center"]). The pipeline notes §0b show the substituent version, with liability flags and parent-relative gating.

Before you rank anything by the DFT tier, read the noise measurement. MEASURED on a real campaign: the best available ΔΔE noise over a pose ensemble was 4.07 kcal/mol, against substituent effects of 1–2 kcal/mol. See the pharma coverage notes.

Related modules in tools/active_site/: ligand_embedding, pose_relaxation, binding_energy, prescreen, pocket_charges, pocket_field.


5. Rank residues for mutation (electrostatic pre-screen)

ferric does not design mutations. It can rank which active-site residues most influence a reactive center, which gives you a short list worth testing instead of a guess.

Sketch, not executed for this page. The two library calls are real. The per-residue split is ordinary Python written for this page, and derive_pocket_charges needs pdb2pqr and a pocket PDB.

from collections import defaultdict
from tools.active_site.pocket_charges import PocketCharges, derive_pocket_charges
from tools.active_site.pocket_field import pocket_field_at_atoms

pocket = derive_pocket_charges("pocket.pdb")   # runs pdb2pqr; fills residue_ids
site_xyz = [(x, y, z)]                         # reactive-center atom(s), Angstrom

# pocket_field_at_atoms returns the TOTAL field. To rank residues, split the
# charges by residue and evaluate each group on its own.
by_res = defaultdict(list)
for q, rid in zip(pocket.charges, pocket.residue_ids):
    by_res[rid].append(q)
contrib = {
    rid: pocket_field_at_atoms(PocketCharges(qs, pocket.source_pdb, pocket.ff), site_xyz)
    for rid, qs in by_res.items()
}   # each value: (N_sites, 4) array of [phi, Ex, Ey, Ez], atomic units

The method:

  1. Compute the field each residue produces at the reactive center.
  2. Rank residues by their contribution. That's your candidate list.
  3. Check the top few with QM/MM (QM/MM; the embedding matches pyscf.qmmm.mm_charge to <1e-8 Ha). This step is what turns the ranking into physics rather than electrostatic hand-waving.

Limits. Include these in any write-up that uses this method. It's a classical point-charge pre-screen, so it has no polarization response of the protein, no sterics, no conformational change on mutation, and no ΔΔG. It ranks hypotheses. A residue it flags is a candidate for QM/MM, not a designed mutation.


6. Comparing against another code

Three causes account for most spurious "ferric disagrees" reports:

Density fitting. ferric's KS-DFT (kind = "ksdft", run_dft) fits the Coulomb term by default, and the fitting error grows with system size. MEASURED at PBE/STO-3G against exact Coulomb: water 0.28, benzene 1.16, and a 71-atom drug molecule 9.5 kcal/mol. Compare like with like. run_dft(..., df_j_aux="exact") turns fitting off, or you can turn it on in the other code.

Grid. ferric's KS-DFT default grid is (75, 110) (radial, angular), flat, with no pruning. PySCF's default grid.level=3 is roughly (75, 302). The difference is worth ~1e-5 Ha on water and grows with atom count, because grid error scales with the number of atoms, not the basis size. A small basis on a big molecule can be worse than a big basis on a small one. Match grids before you conclude anything.

Convergence criteria. Setting energy_conv alone can leave the density loosely converged. Variational quantities (E_HF) don't mind. Anything that depends linearly on the MO coefficients (correlation energies, properties) does. density_conv is the criterion that actually converges the density, so tighten it when you care about those quantities. Keep energy_conv loose: with density fitting, a very tight energy_conv may be unreachable (Golden paths, Step 2).

[scf]
density_conv = 1e-9

Choosing a method

Start from what you want to compute. Each row names a method that ferric implements for that task, where to run it (CLI method.kind or Python only), how far it is validated, and a shipped example to copy. Grades, and the anchors behind them, are on Capabilities and validation. Cost is given as formal scaling with system size N, not as timings.

This page only recommends what the code and its tests support. Where ferric has no validated option for a task, the row says so.

By task

TaskRecommendedWhereGradeScalingExample
Geometry optimizationKS-DFT (e.g. PBE, B3LYP) or RHF, task = "optimize"CLI, Python (run_optimize is RHF)ProvenN⁴h2-lda-opt.toml, h2_opt.toml
Harmonic frequenciesKS-DFT or RHF/UHF/ROHF, task = "frequencies" (finite differences of the analytic gradient, 6N gradients)CLI, Python run_frequenciesenergies Proven; check the printed Hessian asymmetry6N × N⁴water-frequencies.toml
Transition staterun_saddle (P-RFO), then run_irc to confirm which minima it connectsPython only, closed shellsee Capabilities and validation~2(6N+1) gradients + steps—
Conformer or reaction energies, routineKS-DFT + D3(BJ)CLI ksdft + [dft] dispersion = "d3bj", Python run_dft(dispersion=...)DFT Proven; D3(BJ) matches simple-dftd3 to <1e-12 HaN⁴ (DFT)water-pbe-d3bj.toml
Correlated energies, small to mediumRI-MP2CLI rimp2, Python run_rimp2ProvenN⁵water-rimp2.toml
Correlated energies, benchmark quality, smallCCSD(T) (closed shell)Python run_ccsd_t only; CCSD also CLI ccsdCCSD Proven; (T) matches PySCF ~1e-6 Ha on H2O/cc-pVDZN⁶ / N⁷water-ccsd.toml (H2)
Non-covalent interaction energiesAttenuated MP2 in the basis its parameters were fitted for: SCS-MP2(2terfc) or MP2-V in aug-cc-pVTZ, erfc att-MP2 in aug-cc-pVDZ (the basis of the 2012 paper); or KS-DFT + D3(BJ)CLI scs-mp2-2terfc, mp2-v, att-rimp2, ksdftatt-MP2 and 2terfc Proven (as implementations); MP2-V SmokeN⁵water-scs-mp2-2terfc.toml, water-mp2v.toml, water-attmp2.toml
Long-range correlation from responseRS-MP2 + LR-RPA, or PDEP-RPACLI rs-mp2-rpa, pdep-rpaRS-MP2+RPA Smoke; PDEP-RPA ProvenN⁵ (MP2 part); N⁴ (RI-RPA)water-rs-mp2-rpa.toml, water-pdep-rpa.toml
Ionization potentials / electron affinitiesG0W0@PBE (or @HF); U-GW for open shellsCLI gw, Python run_gw, run_u_gwSmoke, about ±0.3 eV—water-g0w0-pbe.toml, oh-ugw.toml
Excitation energiesBSE-TDA on G0W0@HF; or TDA/TDDFT (CIS/TDHF on an HF reference)CLI bse-tda, tda, tddft; Python run_bse_tda, run_tddftBSE-TDA Smoke; TDA/TDDFT Proven (narrow, closed shell)—water-bse-tda.toml, water-tda.toml
Static polarizabilityPDEP-RPA properties, or RPAx@KS (static α only)CLI pdep-rpa with compute_polarizability, tdhf-static-polarizabilityPDEP-RPA Proven (energy); RPAx SmokeN⁴ (RI)h2o-pdep-rpa-props.toml, water-tdhf-static-alpha.toml
\( C_6 \) dispersion coefficientsPDEP-RPA dynamic α (c6_source = "pdep") or TS/MBD, in an augmented basis. Not RPAx: its \( C_6 \) is ~63% lowCLI pdep-rpa + [rpa] compute_c6which source is better is not establishedN⁴ (RI)water-c6-pdep.toml, argon-c6-rpa-pbe.toml
Implicit solvationIEF-PCM (CLI [pcm], Python solvent=) or COSMO (CLI [cosmo])see SCF and DFTboth cross-checked against PySCF on waterSCF cost—
Embedding in a protein or solventQM/MMPython run_qmmm; CLI [qmmm] (point charges)see QM/MMSCF costwater-qmmm.toml
Electron-transfer couplingconstrained DFT + Wu–Van Voorhis \( H_{ab} \)Python run_cdft, cdft_coupling (no CLI)no external referenceSCF cost × outer λ loop—
Atomic charges, ESPLöwdin, Hirshfeld, CHELPG, RESP; ESP at nuclei or on the surfacePythonsee Capabilities and validationSCF cost—

Notes on the table:

  • "Proven" for an attenuated MP2 method means the implementation reproduces its reference, not that the method is accurate on your system. Its parameters are fitted to interaction-energy benchmarks in one basis (the aug-cc-pVTZ sets without counterpoise, with frozen core); in another basis, with counterpoise correction, or with frozen_core = 0 (the default), you are extrapolating. See The MP2 family.
  • KS-DFT uses density fitting by default (def2-universal-jkfit), and the fitting error grows with system size. Turn it off or match it before comparing with an exact-Coulomb code; see SCF and DFT.
  • CC results are sensitive to the aux basis: at CC accuracy the RI error depends on the auxiliary set. See Coupled cluster.

Pairing an orbital basis with its auxiliary basis

Every correlated method needs an RI auxiliary basis ([mp2] auxbasis or [rpa] auxbasis; the auxbasis argument in Python). Use the set built for your orbital basis. The bundled pairs are:

Orbital basisRI (correlation) auxJK aux (for df_j_aux / df_k_aux)
cc-pVDZcc-pvdz-ri (alias cc-pvdz-rifit)def2-universal-jkfit
cc-pVTZcc-pvtz-rifitdef2-universal-jkfit
aug-cc-pVDZ / TZ / QZaug-cc-pvdz-rifit, aug-cc-pvtz-rifit, aug-cc-pvqz-rifitdef2-universal-jkfit
def2-SVP, def2-TZVP, def2-QZVPdef2-svp-rifit, def2-tzvp-rifit, def2-qzvp-rifitdef2-universal-jkfit

def2-tzvpp-rifit and def2-qzvpp-rifit are also bundled as auxiliary sets. The ECP orbital sets aug-cc-pvdz-pp and aug-cc-pvtz-pp have no bundled -pp-rifit partner. STO-3G and 6-31G have no matching RI set; the examples pair them with cc-pvdz-ri or a larger RI set. Only the names above (plus cc-pvdz-f12 and cc-pvdz-f12-optri) load by name; any other set goes in through [basis] path as a Gaussian-94 file (orbital basis only). def2-universal-jkfit is the default JK set for KS-DFT.

When nothing here fits

  • Analytic Hessians cover RHF and UHF (exact J/K, no ECP, basis up to f functions); ROHF, KS-DFT and correlated methods' frequencies are by finite differences of the analytic gradient.
  • Open-shell coupled cluster and open-shell TS search are not available (the one open-shell CC-type method, LinLCCD(hh), is library-only). Open-shell KS-DFT is available from the CLI: ksdft with multiplicity > 1 runs UKS, and uhf/rohf with [dft] functional run UKS/ROKS. See Capabilities and validation: open shells in the CLI.
  • TDDFT with the XC kernel exists only as a library-only spike; the CLI and Python TDDFT omit it. See RPA, GW and excited states.

For PySCF users

ferric's Python API will look familiar if you use PySCF, but it is built differently. This page maps the PySCF calls you already know onto ferric's, then lists the differences that change numbers or cause errors.

The table only lists pairs where both sides exist. If a PySCF feature has no row, assume ferric does not have it; check the full function reference to be sure. The PySCF names were checked against PySCF 2.12.1.

Rosetta table

Assume import ferric and, on the PySCF side, the matching from pyscf import ... (gto, scf, dft, mp, cc, tdscf, gw, lo, df, qmmm, solvent, geomopt, hessian).

Molecule and basis

PySCFferric
mol = gto.M(atom=..., basis="cc-pvdz", charge=0, spin=2)mol = ferric.Molecule.from_xyz_string(xyz, charge=0, multiplicity=3) and bs = ferric.BasisSet.bundled("cc-pvdz")
mol.energy_nuc()mol.nuclear_repulsion()
mol.nelectronmol.nelec()
mol.natmmol.natoms()
mol.atom_coords() (Bohr)mol.coords_bohr()
mol.atom_coords(unit="Angstrom")mol.coords()
mol.elementsmol.symbols()
mol.atom_charges()mol.atomic_numbers() (true Z, not reduced by an ECP)
ghost atom "ghost-H" or "X-H"@H in the XYZ text

SCF and DFT

PySCFferric
mf = scf.RHF(mol).run()r = ferric.run_rhf(mol, bs)
scf.UHF(mol).run()ferric.run_uhf(mol, bs) (spin from mol)
scf.ROHF(mol).run()ferric.run_rohf(mol, bs)
scf.RHF(mol).density_fit(auxbasis="def2-universal-jkfit")ferric.run_rhf(mol, bs, df_j_aux="def2-universal-jkfit", df_k_aux="def2-universal-jkfit")
mf = dft.RKS(mol); mf.xc = "b3lyp"; mf.run()ferric.run_dft(mol, bs, functional="b3lyp") (closed-shell only)
mf.disp = "d3bj"ferric.run_dft(..., dispersion="d3bj"), or ferric.d3bj_energy(mol, "b3lyp") alone
mf.e_totr.energy (RHF/UHF/ROHF) or r.total_energy (DFT)
mf.convergedr.converged
mf.mo_energyr.orbital_energies() (RHF), r.orbital_energies_alpha() / _beta() (UHF/ROHF)
mf.mo_coeffr.mo_coefficients() (RHF only)
mf.make_rdm1()r.density() (RHF, DFT), r.density_alpha() / r.density_beta() (UHF/ROHF)
mf.level_shift = 0.2run_rhf(..., level_shift=0.2)
mf = solvent.PCM(scf.RHF(mol)); mf.with_solvent.eps = 78.4ferric.run_rhf(mol, bs, solvent=78.4) or solvent="water"
qmmm.mm_charge(mf, coords, charges)run_rhf(..., point_charges=[(q, x, y, z), ...]), coordinates in Bohr

Geometry and vibrations

PySCFferric
geomopt.geometric_solver.optimize(mf)ferric.run_optimize(mol, "cc-pvdz") (RHF; basis by name)
mf.Hessian().kernel() then thermo.harmonic_analysis(mol, h)ferric.run_frequencies(mol, "cc-pvdz")

Correlation, response and properties

PySCFferric
mp.MP2(mf).density_fit(auxbasis="cc-pvdz-ri").run()ferric.run_rimp2(mol, bs, ferric.BasisSet.bundled("cc-pvdz-ri"))
cc.CCSD(mf).density_fit(auxbasis=...).run()ferric.run_ccsd(mol, bs, aux)
mycc.ccsd_t()ferric.run_ccsd_t(mol, bs, aux).t_correction
tdscf.TDA(mf).run()ferric.run_tddft(mol, bs, aux, method="tda")
tdscf.TDHF(mf).run() / tdscf.TDDFT(mf).run()ferric.run_tddft(mol, bs, aux, method="casida")
gw.GW(mf).kernel()ferric.run_gw(mol, bs, aux)
lo.Boys(mol, mo).kernel()ferric.boys_localize(mol, bs, mo).c_loc()
mf.mulliken_pop()ferric.mulliken_charges(mol, bs, r)
df.incore.aux_e2(mol, auxbasis)ferric.compute_eri3(mol, bs, aux)
df.incore.fill_2c2e(mol, auxbasis)ferric.compute_metric_2c(mol, bs, aux)

How ferric differs

Stateless calls, not mutable method objects. PySCF builds an mf object, lets you set attributes on it, then runs it. ferric has one function per calculation. Settings are keyword arguments, and the call returns a result object. There is nothing to reconfigure and rerun; call the function again. Correlated drivers run their own reference SCF, so you pass them the molecule and basis, not a converged mf.

Spin is multiplicity, and it lives on the molecule. PySCF's spin is 2S; ferric's multiplicity is 2S+1. It is set when the molecule is built (from_xyz_string(..., multiplicity=3)). run_uhf and run_rohf have no spin keyword.

The auxiliary basis is always explicit. PySCF picks an auxiliary basis for you when you call .density_fit() without one. ferric's MP2, CC, RPA, GW and TDDFT drivers take the RI basis as a required argument. Only SCF-level fitting has a default: run_dft and run_ksdft fit J (and K for hybrids) with def2-universal-jkfit unless you pass df_j_aux/df_k_aux. These fit their reference SCF with def2-universal-jkfit and take no override: run_qmmm with a KS method, run_gw and run_u_gw with xc, run_tddft with a functional, run_tdhf_static_polarizability, run_double_hybrid, and run_rs_mp2_rpa (its HF reference).

Density fitting is on in run_dft and off in run_rhf. run_dft uses RI-J by default, and RI-K for hybrids. dft.RKS in PySCF uses exact Coulomb unless you call .density_fit(). run_rhf builds exact four-centre J and K unless you pass df_j_aux/df_k_aux. The difference between a fitted and an exact energy is the fitting error, not a bug; the ferric source records it at PBE/STO-3G against conventional J as 0.28 (water), 1.16 (benzene) and 9.5 kcal/mol (a 71-atom drug molecule). Pass df_j_aux="exact" to run_dft when you compare against exact-Coulomb PySCF.

The DFT grid is smaller and not pruned. ferric's default is 75 radial × 110 Lebedev angular points on every atom, with no pruning. PySCF's default (grids.level = 3) uses 302 angular points for H through Ne and 434 from Na on, with 50 radial points for H and 75 for C–Ne, and prunes with nwchem_prune. Expect grid-level differences in DFT energies.

Correlated methods use RI integrals throughout. ferric's CCSD and CCSD(T) build their integrals from the auxiliary basis you pass. The fair PySCF comparison is cc.CCSD(mf).density_fit(...), not plain cc.CCSD(mf), which uses exact four-index integrals.

Range-separation ω is in Å⁻¹ in the run_* drivers. omega on run_attenuated_rimp2 and run_rs_mp2_rpa is in Å⁻¹ (default 0.420). PySCF, and ferric's low-level compute_eri3_mo and compute_metric_2c, take Bohr⁻¹.

Point charges are in Bohr; QmmmSystem coordinates are in Å. point_charges= on run_rhf and the other SCF drivers takes Bohr. PySCF's qmmm.mm_charge defaults to mol.unit, which is Ångström unless you changed it. ferric.QmmmSystem takes Ångström coordinates, and its point_charges() accessor returns Bohr, ready to pass to run_rhf.

An unconverged SCF can raise. PySCF's kernel() returns the energy either way. ferric's run_rhf, run_uhf, run_rohf and run_qmmm do the same, so check .converged. run_dft and every correlated driver raise instead when their SCF does not converge.

TDDFT refuses some functionals. ferric's run_tddft includes the f_xc response and matches PySCF TDA/TDDFT to 1e-3 eV for HF, LDA, PBE and B3LYP on the tested systems. It refuses meta-GGA, VV10 and range-separated functionals, which PySCF accepts, and it runs closed-shell references only.

GW defaults to a Hartree–Fock reference. PySCF's gw.GW(mf) runs on whatever mf you pass, usually a DFT one. ferric.run_gw runs its own HF reference unless you pass xc="pbe" or another functional.

The PCM defaults differ. ferric's solvent= is IEF-PCM with 110 tesserae per atomic sphere (pcm_lebedev_order=110). PySCF's PCM defaults to C-PCM with Lebedev order 29 (302 points per sphere) and SWIG discretization. The named solvent "water" is ε = 78.4 in ferric; PySCF's default ε is 78.3553.

AO matrices do not match element by element. ferric's AO basis-function conventions come from libint2 and are not the same as PySCF's (libcint). MEASURED on CO/cc-pVDZ: RHF total energies agree to 3e-12 Ha and orbital energies to 8e-9 Ha, while the two AO density matrices differ element by element by up to 1.5. Compare invariant quantities (energies, orbital energies, charges), not raw AO matrices. Axis order differs too: compute_eri3 returns (naux, n_bf, n_bf), with the auxiliary index first.

Basis sets come from a fixed bundled list. BasisSet.bundled(name) knows 25 names (see Bundled basis sets). There is no Python loader for a basis file or for a per-element basis dictionary.

Results come back as numpy arrays or lists, not live objects. Matrices are numpy.ndarray. Per-atom quantities such as charges are Python lists.

QM/MM

Run a quantum region inside a classical environment: a ligand in a protein pocket, a reacting site in an enzyme, a solute in explicit solvent. The QM atoms get a real wavefunction; everything else becomes point charges that polarize it.

What you get: QM region selection (by index, by radius, or by whole residue), link atoms across covalent cuts with four boundary-charge schemes, Gaussian-smeared charges, Thole polarizable embedding, an AMBER-form MM crate, analytic QM and MM forces, a full-structure gradient across the cut, geometry optimization, and a TIP3P solvation droplet. Structures come from PDB, mmCIF, PQR, SDF, mol2, GROMACS .gro, XYZ or SMILES.

What is missing: no AMBER prmtop reader (go through OpenMM), and no periodic boundary conditions — the solvation droplet is finite, with a vacuum boundary.


Before you start: relax the geometry with xtb

The single highest-value habit in this whole workflow. DFT on an unrelaxed structure wastes hours and often simply fails.

Measured on this project, a 27-atom carbocation built by hand:

hand-built    C7-H24 = 0.902 Å      SCF: Stalled@37, never converged
xtb GFN2      C7-H24 = 1.084 Å      SCF: converged
                                    545 kcal/mol lower

A 0.9 Å C–H bond is not a slightly-off geometry; it is a structure whose SCF has no reason to converge. xtb finds that in seconds where DFT spends hours failing.

xtb input.xyz --opt --gfn 2 --chrg 1        # seconds
ferric single_point.toml                     # then the expensive step

Use it for the initial geometry of anything you did not get from a crystal structure or a previous optimization — hand-built ligands, docked poses, edited residues, anything from a SMILES string. See Applications for the full docking → xtb → DFT funnel.

Build note: xtb built with -O3 miscompiles gradients on this platform. Build with -Doptimization=2.


The smallest working example

import ferric

# The FULL structure: every atom, QM and MM alike.
symbols = ["O", "H", "H",   "O", "H", "H"]     # two waters
coords  = [[0.0, 0.0, 0.0], [0.76, 0.59, 0.0], [-0.76, 0.59, 0.0],
           [0.0, 0.0, 3.0], [0.76, 0.59, 3.0], [-0.76, 0.59, 3.0]]
charges = [-0.834, 0.417, 0.417,  -0.834, 0.417, 0.417]   # MM partial charges (e)

# QM = the first water; the second becomes point charges.
sys = ferric.QmmmSystem(symbols, coords, charges, qm_indices=[0, 1, 2])

res = ferric.run_qmmm(sys, "cc-pvdz", method="rhf")
print(res.energy)

charges are ignored for atoms that end up in the QM region, so you can pass a full force-field charge set and let the selection decide.

Selecting the QM region

Three ways, and the choice matters more than it looks:

# 1. Explicit indices — full control.
ferric.QmmmSystem(symbols, coords, charges, qm_indices=[0, 1, 2])

# 2. Everything within a radius of some seed atoms.
ferric.QmmmSystem(symbols, coords, charges,
                  qm_seeds=[0], qm_radius_angstrom=5.0)

# 3. Whole residues — a residue joins if ANY of its atoms is in range.
#    Prefer this for proteins: a radius cut alone will slice through a
#    residue and leave you with a chemically meaningless fragment.
ferric.QmmmSystem(symbols, coords, charges,
                  qm_seeds=[0], qm_radius_angstrom=5.0,
                  residue_ids=residue_ids)

residue_ids together with qm_indices is an error, not a silent no-op — an explicit index list and a residue expansion are contradictory instructions.

Cutting a covalent bond

If the QM/MM boundary crosses a bond, you need a link atom and a scheme for what to do with the host's charge:

sys = (ferric.QmmmSystem(symbols, coords, charges, qm_indices=qm)
       .with_link_atoms(bonds)                        # bonds crossing the cut
       .with_boundary_charges(bonds, "rcd"))          # keep | delete-host | rc | rcd
schemewhat it does
keepleave the host charge in place
delete-hostZ1 — delete it
rcLin–Truhlar redistributed charge
rcdredistributed charge and dipole

The parser is strict: an unknown string is an error, never a silent default.

Energies, forces, and optimization

res = ferric.run_qmmm(sys, "cc-pvdz", method="uhf")
# KS-DFT: method="rks" or "uks" AND xc, e.g. method="rks", xc="PBE".
# xc without rks/uks (or rks/uks without xc) raises ValueError.
res.energy
res.qm_gradient()      # dE/dR on the QM atoms
res.mm_forces()        # forces on the MM sites
res.full_gradient()    # across the cut, link rows folded back onto the frontier

opt = ferric.run_optimize_qmmm(sys, "cc-pvdz", method="rhf", move_mm="none")

move_mm chooses which MM atoms relax: "none" (the default, where only QM atoms move), ("within", radius_angstrom), ("residues", [ids]) (the system must have been built with residue_ids=), or "all". Any value other than "none" requires mm_topology=, because moving MM atoms without a force field to hold their own geometry together is meaningless.

Polarizable embedding

Thole polarizable sites, if fixed charges are not enough:

sys = ferric.QmmmSystem(symbols, coords, charges,
                        qm_indices=qm,
                        polarizabilities_angstrom3=alphas)
res = ferric.run_qmmm(sys, "cc-pvdz", method="rhf", thole_a=2.1304)
res.e_pol                  # the polarization energy
res.induced_dipoles()

What is validated, and how far

Checked against pyscf.qmmm.mm_charge: energy shift agrees to <1e-8 Ha, MM forces to 3e-10. An empty or all-zero MM region is bit-identical to a gas phase calculation — the trivial limit is a genuine no-op, not an approximation that happens to be small.

Not validated: periodic boundary conditions (absent), and the solvation droplet, which is a hard-sphere packing at roughly bulk density rather than an equilibrated box.

Reading a structure

Every format lands in the same place, so a PDB and an XYZ of one molecule give a bit-identical Molecule:

from tools.structure import read, from_smiles

mol = read("ligand.pdb")        # or .cif .pqr .sdf .mol2 .gro .xyz
mol = from_smiles("CCO")        # ETKDG geometry -- tier-2 grade, NOT optimized

A PQR carries MM charges as well as coordinates, which is why the CLI's [qmmm] section reads one. Note that a docked pose from Vina is united-atom — nonpolar hydrogens are merged into their carbons — so it is not a QM geometry until those are restored; tier1_dock does that for you.

From the CLI

A [qmmm] section runs QM/MM from a TOML file, with no Python needed. The QM region becomes the molecule that is solved, and the MM region becomes the external potential it is solved in. A complete, runnable input is examples/water-qmmm.toml (repository, not the wheel):

[molecule]
xyz = "testdata/molecules/water.xyz"   # required by the parser, NOT read with [qmmm]
charge = 0                             # apply to the QM region
multiplicity = 1

[basis]
name = "sto-3g"

[method]
kind = "rhf"
task = "energy"

[qmmm]
pqr = "testdata/molecules/water_na.pqr"
qm_indices = [0, 1, 2]            # zero-based; or: qm_seeds = [0], qm_radius_angstrom = 1.5
# link_bonds = [[0, 3]]           # required when the cut crosses a covalent bond
# boundary_scheme = "delete-host" # default; also "keep", "rc", "rcd"

MEASURED on that file (water QM, one Na⁺ 4 Å away as MM, STO-3G): vacuum −74.9629466809, embedded −74.9653197421 Ha (−1.489 kcal/mol). That matches ferric.run_rhf(point_charges=...) to all ten printed digits.

The geometry and the MM charges both come from the PQR. An xyz has no partial charges, and an MM region without charges is just a set of ignored coordinates. [molecule] xyz must still be present, because the parser requires it, but it is not read when [qmmm] is present. That matters when you compute a vacuum reference: delete [qmmm] and ferric falls back to the xyz. If the xyz holds a different geometry, you are comparing two different molecules. In the example above, the xyz is an optimized water that gives −74.9631468000, which is 0.13 kcal/mol of error in the difference. Compare at one geometry.

boundary_scheme defaults to "delete-host", not "keep". Keeping the host charge across a covalent cut puts a bare point charge inside the link atom's bond length (MEASURED 0.443 Å on an ethane C–C cut). A geometry optimization in that field diverges instead of failing with an error. "keep" is still available and is the right choice when the cut isn't covalent. An unknown scheme name is an error.

[qmmm] and [external_potential] together are refused. The MM region is an external potential, so combining them would silently count a contribution twice.

Scope of the CLI section. It reads only a PQR, because it needs charges and geometry together. Use Python (tools.structure, below) for the other formats. tools.active_site.solvate can write a solvated system straight to a PQR for this section. The CLI section does electrostatic embedding only. Polarizable sites, smeared charges, the MM force field and MM relaxation are Python-only.

Solvating a solute

from tools.active_site.solvate import solvate, write_pqr

drop = solvate(symbols, coords_angstrom, radius_angstrom=12.0)
write_pqr("solvated.pqr", symbols, coords_angstrom, charges, drop)

The solute is written first, so its indices are 0 .. n-1 and can go straight into [qmmm] qm_indices. Waters are TIP3P at roughly bulk density; this is a starting structure, not an equilibrated one, and a droplet has a vacuum boundary. Repeat over several seed= values before trusting a difference — dE_statistics does that and refuses fewer than two seeds.

Known limits, stated plainly

  • No AMBER prmtop reader. Go through OpenMM (active_site.mm_topology.topology_from_openmm), or build the arrays.
  • No periodic boundary conditions. solvate() gives a finite droplet with a vacuum boundary — adequate for a local environment, not for bulk.
  • The pocket field is fixed unless you ask otherwise. move_mm="none" is the default in run_optimize_qmmm; the MM sites do not relax with the QM region until you widen it.
  • No QM/MM dispersion. D3/D4/XDM/VV10 are all QM-atom-pairwise, so dispersion between the QM region and the MM charges is absent. The MM crate supplies Lennard-Jones terms for the MM-MM part only.
  • The MM crate is AMBER-form (harmonic bonds and angles, periodic torsions, Lennard-Jones, Coulomb), validated against OpenMM.

Golden paths: ferric in applications

End-to-end workflows for real questions, written so an agent can execute them without reading the rest of the repo. Each one states what it answers, what it cannot, and how to tell it went wrong.

Read For agents first if you are automated — it has the failure modes that cost the most time. Individual steps live in Recipes.


Golden path A — Reaction energetics of a cation cascade

Answers: which intermediate is the bottleneck, and which branch point controls product selectivity.

Worked example: terpene synthase carbocation cascades (geranyl → linalyl → α-terpinyl → product). The same shape applies to any closed-shell cation mechanism.

Step 0 — gate before you spend hours

pip install ferric
git clone https://github.com/mgoldey/ferric && cd ferric   # examples/, testdata/, tools/, scripts/
ferric examples/water-rhf.toml    # expect converged = true, energy = -74.9631468000

Don't skip this, and don't build from source first. The wheel installs in about a minute, and a source build takes ~30 minutes. If the install is broken, every later failure will look like a chemistry problem. The clone is needed because the wheel doesn't ship examples/, testdata/, scripts/ or tools/.

Run the study itself under scripts/ferric-limited --max=8G --high=7G --. These can be multi-hour jobs, and an OOM kill leaves a truncated log with no error message.

Step 1 — build the species, and check parity

Generate starting geometries however you like (RDKit ETKDG + MMFF is fine — force fields are for geometry seeds only, never for a reported energy).

Then, before any QM, verify electron parity for every species:

n_elec = sum(ATOMIC_NUMBER[sym] for sym in symbols) - charge
assert (n_elec % 2 == 0) == (multiplicity % 2 == 1), (
    f"{name}: {n_elec} electrons cannot have multiplicity {multiplicity}"
)

A cation is charge=1, multiplicity=1. Getting this wrong is the single most common first failure; ferric catches it with a clear message, but checking in your own loop fails faster and names the species.

The parity gate is NOT a geometry check, and you need both. A structure can have a perfectly correct formula and still be physically impossible. MEASURED: a hand-built C10H17+ passed parity (76 electrons, singlet) while carrying a 0.902 A C-H contact -- shorter than a real bond -- and a hydrogen sitting 1.030 A from a second carbon. The SCF stalled at 37 iterations. That is the correct behaviour for overlapping nuclei, and it cost a run to discover.

# formula check and geometry check fail on DIFFERENT mistakes -- keep both
assert (n_elec % 2 == 0) == (multiplicity % 2 == 1)   # bad stoichiometry
assert min_interatomic_distance(xyz) > 0.9            # gross overlap only (your own helper, Angstrom)
# The failure above PASSES that screen (closest contact 0.902 A): its H sat
# ~1 A from two carbons at once. Also reject any H bonded to two heavy atoms:
assert max_heavy_neighbours_of_hydrogen(xyz, cutoff=1.3) <= 1

Step 1b -- relax with xtb before ANY DFT

This is the middle tier of the funnel and skipping it is expensive. GFN2-xTB fixes a bad structure in seconds:

LD_LIBRARY_PATH=~/.local/lib/x86_64-linux-gnu \
  xtb mol.xyz --opt --chrg 1 --uhf 0 --gfn 2
# -> xtbopt.xyz

LD_LIBRARY_PATH is not optional on a local install: bare xtb fails with libxtb.so.6: cannot open shared object file, which reads like a broken install and is not one.

MEASURED on the same molecule, same method, same basis:

hand-built geometry : closest contact 0.902 A -> SCF Stalled @37, E = -389.5110690
xtb-relaxed (54 its): closest contact 1.084 A -> SCF converged,   E = -390.3794234

0.868 Ha = 545 kcal/mol lower, and the difference between a result and a failure. Seconds of xtb bought that.

Step 2 — optimize each intermediate

[molecule]
xyz = "alpha_terpinyl.xyz"
charge = 1
multiplicity = 1

[basis]
name = "6-31g"           # def2-SVP did not fit on a workstation here; see below

[method]
kind = "ksdft"
task = "optimize"

[dft]
functional = "PBE"

[scf]
max_iter = 400           # 173 iterations was needed; the default is 100
energy_conv = 1e-7       # a loose, reachable bound; see below
# density_conv defaults to 1e-6 and is the criterion that does the real work.
# Tighten it if you need more than the energy; see below.

[optimize]
max_steps = 60

These settings follow from the measurements below. Don't raise the basis or tighten energy_conv without measuring one species first.

Delocalised cations need iterations, and a reachable energy_conv. MEASURED on the same 27-atom cation, changing only these two knobs:

max_iter 100, energy_conv 1e-8  ->  MaxIter @100,  29 min, 3.30 GB, no result
max_iter 400, energy_conv 1e-7  ->  CONVERGED @173, 4.9 min, 1.81 GB

Two things to take from that. 173 iterations is normal for a delocalised carbocation -- the default cap is not sized for this. And energy_conv: 1e-8 was unreachable: under density fitting the energy change floors with naux, so a tighter dE than the RI noise floor can never be met and the run burns its whole iteration budget chasing it. density_conv is the criterion that does the real work in this codebase; leave the dE bound loose enough to be satisfiable.

Note the failure MODE is diagnostic. Stalled means the SCF could not make progress -- suspect the geometry. MaxIter means it was progressing and ran out of room -- raise the cap. They are different problems and the exit reason names which one you have.

Set density_conv, not just energy_conv. E_HF is variational and tolerates a loose density; anything depending linearly on the MO coefficients does not. A correlation energy computed from an unconverged density can look reproducible and still shift when anything upstream changes, because the density was never converged.

Checkpoint every species as it finishes. A crash at species 6 of 8 should cost one species, not the whole run.

The method and basis decide whether the study is possible at all. MEASURED on the same 27-atom cation, single point (both the basis and the functional differ between the two rows, so this does not isolate the cost of the basis):

def2-SVP / B3LYP : >22 min, 6.04 GB, SIGKILLed before converging
6-31G   / PBE    :  4.9 min, 1.81 GB, CONVERGED

6-31G/PBE used a third of the memory and finished. Don't plan a def2-SVP cascade on a workstation without measuring one species first.

Treat the optimization time as an estimate. An optimization takes tens of gradient steps. ESTIMATED: at ~5 min per 6-31G single point, 30–60 steps would take roughly 2.5–5 hours per species if each gradient step costs about as much as a single point. Neither a gradient step nor a full optimization of this system has been timed. Later steps start from the previous density and may converge faster than a cold start. Time one optimization before budgeting a cascade.

Use a release build. MEASURED: the same cation at B3LYP/def2-SVP ran for more than 34 minutes on a debug binary, sharing the machine with one other job, without converging a single point. The wheel and cargo build --release are both release builds.

A 27-atom cation at def2-SVP needs ~6 GB. Budget the study with that in mind. Running eight species of that size at once is an outage, not a plan. Always wrap the run in scripts/ferric-limited --max=8G --high=7G --. For agents explains why the in-process budget alone is not enough.

Size it before you start. MEASURED: the tert-butyl cation (C4H9+, 101 basis functions at def2-SVP) is a single point that takes seconds. A C10 cascade cation (C10H17+) has 225 basis functions. ESTIMATED at N^3.5 scaling, that is about 16x the single-point cost. That's still cheap for an energy, but an optimization multiplies it by the step count. So the optimizations of the C10 species are the dominant cost of the whole study. Run one species end to end and time it before you queue eight.

Step 3 — confirm the ordering survives the functional

Run single points on the optimized geometries with at least two more functionals, for example B3LYP and wB97X-V (both available; wB97X-V resolves to libxc's HYB_GGA_XC_WB97X_V), at a basis that fitted in Step 2.

If the functionals disagree on the ordering of intermediates, report the disagreement. Do not average them and do not pick the one that matches your hypothesis. A cascade whose ordering is functional-dependent is a finding about the system, not a number to be cleaned up.

Step 4 — properties at the key intermediate

Hirshfeld and Löwdin charges, the ESP at the nuclei, and the static polarizability of the π-stabilized cation. From Python these are ferric.hirshfeld_charges, ferric.lowdin_charges and ferric.esp_at_atoms.

Cost warning: the polarizability comes from ferric-rpa and is an RPA-level calculation, not a cheap add-on to the DFT run. Budget it separately. It can cost more than the optimization before it.

Step 5 (optional) — barriers

Intermediates alone give you a thermodynamic profile. If you need barriers, ferric has a Python-only transition-state toolchain. None of it is wired to the CLI's method.task:

CallWhat it doesScope
ferric.run_saddle(mol, basis, xc=...)P-RFO search for a first-order saddle pointClosed shell only (multiplicity 1), HF or KS. It raises if the start has no negative Hessian mode, so start from a guessed TS, not a minimum. The Hessian is built twice by central differences and Bofill-updated in between: 2(6N+1) + (steps+1) gradients.
ferric.run_irc(mol, basis, mode=...)Follows the reaction path downhill in both directions, from the saddle to the two minima it connectsClosed shell only. Pass SaddleResult.imaginary_mode as mode. MEASURED ~71 gradients per direction on NH3 inversion.
ferric.run_frequencies(mol, basis, reference=..., xc=...) (CLI: task = "frequencies")Harmonic frequencies from finite differences of the analytic gradient (6N gradients). Negative entries are imaginary modes. .normal_modes gives the vectors.RHF/UHF/ROHF and their KS variants. Check .asymmetry to judge whether the step size suited the system. dispersion="d3bj" or "mbd" (CLI: [dft] dispersion) adds the dispersion Hessian on closed-shell KS.

One imaginary frequency is necessary but not sufficient for a transition state: a methyl rotor also gives one. Use run_irc to confirm that the saddle connects the two intermediates you meant.

What this cannot tell you

State these in any write-up. They aren't hedging. They define the scope of the claim.

  • Gas-phase cluster models. There's no enzyme environment unless you add QM/MM (golden path B, and QM/MM).
  • Analytic Hessians for RHF and UHF only. RHF and UHF frequencies use the analytic Hessian; ROHF, KS-DFT and embedded frequencies, and every TS search, use finite differences of analytic gradients (Step 5). That costs 6N gradients per Hessian, so a C10 cation Hessian is hundreds of gradient evaluations. Without Step 5 you have intermediate energies, not barriers, and kcat depends on barriers.
  • No thermochemistry. There's no entropy, enthalpy or free-energy correction anywhere, from the CLI or Python. You can compute a zero-point energy yourself from the frequencies (½Σhν over the real modes). Python's FrequencyResult doesn't provide one.
  • Relative energies only. Absolute totals carry basis-set and functional errors far larger than the differences you are interpreting.

Golden path B — Which residue should I mutate?

Answers: which active-site residues most influence a reactive center, so you have a ranked short list.

Does not answer: what mutation to make. ferric ranks hypotheses. It does not design, and it does not predict ΔΔG.

Step 1 — classical pre-screen (cheap, all residues)

derive_pocket_charges (in tools/active_site/pocket_charges.py) runs pdb2pqr on the pocket and records each charge's residue (residue_ids, res_names). pocket_field_at_atoms returns the potential and field [phi, Ex, Ey, Ez] (atomic units) at the sites you give it. It returns the total. To rank residues, group the charges by residue and evaluate each group separately. Recipes §5 has a sketch of the code.

Step 2 — QM/MM the top few (expensive, short list only)

Use QM/MM (ferric.QmmmSystem + ferric.run_qmmm). The embedding energy shift matches pyscf.qmmm.mm_charge to <1e-8 Ha. This step is what turns the answer into physics rather than electrostatics.

The pre-screen exists to make this step affordable. Running QM/MM on every residue is the thing the funnel pattern is designed to avoid.

Limits

Classical point charges: no protein polarization response, no sterics, no conformational change on mutation, no ΔΔG. A flagged residue is a candidate for QM/MM, not a designed mutation.


Golden path C — Screen many ligands down to DFT

Answers: of N candidates, which few deserve expensive QM.

Use tools.pipeline.run_funnel (repository tools/, not the wheel). Don't write your own loop. Recipes §4 has a funnel you can run (force field → xtb → DFT on three isomers, with its measured output). The pipeline notes §0b show the substituent version: parent-relative gating, liability flags and the optional docking tier.

Why use it instead of your own loop:

  • Failed candidates are dropped and counted, never ranked. A hand-written screen that sorts ascending on a sentinel value silently promotes its failures to the top. The funnel exists to prevent that bug.
  • Per-tier wall times. The tier that actually dominates is often not the one the cost table predicts. MEASURED in both recorded runs: the DFT tier took 90–96% of the wall time.
  • Early stop on an empty population.
  • Ascending rank at every tier, since each tier reports an energy-like score. Rank only candidates with the same formula, or rank relative to a parent.

The funnel will produce an ordering that the noise doesn't support. MEASURED on a real campaign: the best available ΔΔE noise over a pose ensemble was 4.07 kcal/mol, against substituent effects of 1–2. Read the pharma coverage notes before you rank anything.


Reading the output

SignalMeaning
converged = trueTrust the energy. Absence of this line is not success.
converged = falseAn energy is still printed. It is NOT a result.
exit Some(Stalled)The SCF could not progress. Suspect the GEOMETRY -- check the minimum interatomic distance and relax with xtb.
exit Some(MaxIter)It WAS progressing and ran out of iterations. Raise max_iter (173 is normal for a delocalised cation) and check energy_conv is reachable under DF.
exit Some(Diverged)The energy climbed for several iterations in a row. Suspect the geometry or the charge/multiplicity before the solver.
charge/multiplicity errorYour .xyz atom count is wrong. Read the arithmetic it prints.
Disagreement with another code ~1e-5 HaCheck the grid: ferric (75,110) vs PySCF ~(75,302). Scales with atom count.
Larger KS-DFT disagreement that grows with sizeferric density-fits Coulomb by default in KS-DFT. See Recipes §6.
A correlation energy that moves when nothing physical changeddensity_conv was never set.

Before publishing any number

Check the capability's grade in Capabilities and validation. A method being available from the CLI doesn't mean its numbers are production-grade. The grades are Proven, Proven (narrow), Smoke and Spike; a few kinds are not graded.

Toxicity screening

Structural-alert and predicted-liability readouts for a molecule, from the command line. This is a repository tool (tools/tox), not part of the wheel. Run it from a git clone of ferric with RDKit installed.

python -m tools.tox --offline "CC(=O)Oc1ccccc1C(=O)O"

The screen combines a local RDKit pass with two optional web providers. The local pass checks several hundred compiled SMARTS patterns from six published alert catalogs (Brenk, PAINS, NIH, and the Glaxo, Dundee and BMS sets via ChEMBL), plus Lipinski and Veber rules, and needs no network.

What the web providers return (probed 2026-09-23):

  • ADMETlab 3.0's documented POST /api/admet returns HTTP 404. The client uses the live but undocumented POST /api/single/admet instead, one molecule per request. The host rate-limits hard, so expect HTTP 429, reported as an unavailable provider (exit 3), on larger batches.
  • ProTox-3.0 has no documented JSON API, and by design the provider never scrapes the HTML results page. It never contributes endpoints: every online run lists it as unsupported, and it never affects the exit status.

For a batch, --offline is still the safer default. The local screen needs no network, and it is the part whose output does not depend on a third-party service being up.

Usage

python -m tools.tox [--offline | --require-online] [--fail-on-alerts]
                    [--timeout SECONDS] [--json] SMILES|FILE [SMILES|FILE ...]
flageffect
--offlinelocal RDKit screen only; makes no network call
--require-onlinean unavailable online provider is a hard failure (exit 2), not a degraded run (exit 3). Cannot be combined with --offline
--fail-on-alertsexit 4 when any molecule has a structural alert
--timeout SECONDSwall-clock limit per web request (default 20). A provider that does not answer is not retried for the rest of the run
--jsonmachine-readable output on stdout, including each provider's provider_status

An input is read as a file when it exists and ends in .smi, .smiles or .txt; otherwise it is treated as a literal SMILES. A file holds one <smiles> [label] per line, and # starts a comment.

# candidates.smi
CC(=O)Oc1ccccc1C(=O)O    aspirin
CN1C=NC2=C1C(=O)N(C)C(=O)N2C   caffeine
python -m tools.tox --offline candidates.smi

Exit status

codemeaning
0clean: every molecule assessed by every provider that was asked to
1usage or input error: a bad flag, an unparseable SMILES, a duplicate label, or nothing to assess
2a required check did not run: the local screen failed, a provider raised instead of returning a result (contract_violation), or --require-online was given and an online provider was unavailable
3online checks unavailable: the local screen ran and is reported in full; each unavailable provider is named with its reason
4structural alerts found (only with --fail-on-alerts)

When several apply, the precedence is 1 > 2 > 4 > 3 > 0.

These are distinct on purpose. "No alerts found" and "the alert screen did not run" produce similar-looking output, and they mean opposite things — so a provider failure is never folded into success. A web-service outage is neither a usage error nor a verdict, so it has its own status: 3 means the local results are usable and the online predictions are missing. If a pipeline needs the online predictions, pass --require-online. Without --fail-on-alerts, alerts are reported but do not change the exit status.

Each provider's outcome is in the JSON as provider_status: ok, unavailable (outage, HTTP error, rate limit, timeout, unparseable response), unsupported (ProTox), no_result, error or contract_violation. The human output shows the same tag in brackets on its !! lines.

Reading the output

Abridged, for aspirin with --offline (each line is followed by a one-line explanation, omitted here):

  alert_brenk                            0.3333 probability  [higher=worse] rdkit-alerts
  alert_pains                                 0 probability  [higher=worse] rdkit-alerts
  alert_total_count                           2 count        [higher=worse] rdkit-alerts
  desc_clogp                               1.31 log10        [higher=worse] rdkit-alerts
  desc_mw                                 180.2 Da           [higher=worse] rdkit-alerts
  lipinski_violation_fraction                 0 probability  [higher=worse] rdkit-alerts

Every line states its polarity, and the JSON carries the same flag as higher_is_worse. An aggregator that guesses the direction will invert a safety ranking, so read the flag rather than assuming one. ADMETlab's F20%/F30% columns are an example of how easy the guess is to get wrong: they are the probability of low oral bioavailability (below 20% or 30%), so they are reported as low_bioavailability_20pct and low_bioavailability_30pct, higher is worse. Reading them as bioavailability, higher is better, inverts them.

A value of None means unknown, never zero. For a probability-valued endpoint, 0.0 means "confidently predicted negative", which is the opposite of "no information".

What the alert scores are not

The alert_* endpoints are scaled hit counts (n/3, capped at 1.0). The output labels them, like lipinski_violation_fraction and veber_violation_fraction, with the unit probability. That label marks a 0–1, higher-is-worse scale, and it is what puts these endpoints into the mean that ToxAssessment.liability_score (the rank-only aggregate on the object assess_smiles returns) takes over every probability-unit endpoint; alert_total_count (unit count) stays out of it. The number itself is a rank-only liability density, not a probability of toxicity, and each alert line's explanation says so. A molecule with zero alerts is not thereby safe: danuglipron screens clean across all six catalogs and was discontinued for a liver signal. Structural alerts catch known problem substructures; they say nothing about dose, exposure or on-target pharmacology.

From Python

from tools.tox.assess import assess_smiles, assess_many

a = assess_smiles("CCO", include_web=False)
for e in a.endpoints:
    if e.known:
        print(e.name, e.value, e.units, "higher_is_worse" if e.higher_is_worse else "")

# A batch reuses ONE provider list: compiling the SMARTS catalogs dominates
# the runtime of a per-molecule loop.
results = assess_many({"aspirin": "CC(=O)Oc1ccccc1C(=O)O"}, include_web=False)

assess_many returns results in input order and does not rank them — ranking depends on what else is being traded off, so it is the caller's job.

ferric for agents

Read this first if you are an automated agent driving ferric. It's a routing page: what exists, where each thing is documented, and which mistakes cost the most time. The linked pages hold the details.

Start here

Don't build from source unless you need to. The prebuilt wheel installs in about a minute. A source build takes ~30 minutes, most of it libint2.

pip install ferric
git clone https://github.com/mgoldey/ferric && cd ferric   # for examples/, testdata/, tools/, scripts/
ferric examples/water-rhf.toml

Expect converged = true and energy = -74.9631468000 Hartree. If you don't get that, stop and fix the install (Installation). Nothing else will work, and the failures will be confusing.

pip install puts a ferric command on PATH that runs the same CLI as the source build. The wheel holds only the compiled library and that command. The examples/, testdata/, scripts/ and tools/ directories need the clone.

Which wheel

ChannelWhatHow
PyPIBuilt by the Wheels workflow on a v* tag. So far only pre-releases (0.1.0rc*) exist, and pip and uv pick them when there's no final release.pip install ferric
GitHub nightlyBuilt every night (53 10 * * * UTC) from main. It's a workflow-run artifact, not a PyPI upload.Download it from the Wheels workflow run, then pip install ./<wheel>

Use the nightly artifact when you need a fix that has landed on main but hasn't been tagged. Don't build from source just to get an unreleased fix.

Build from source when you are changing ferric or need MPI. MPI builds are source-only (see Installation).

Capability → entry point

You wantUseWhere it's documented
Which methods exist, with which tasks and validation grade—Capabilities and validation
An energy from a SMILES stringtools.structure.from_smiles + ferric.run_dftRecipes §0
An energy from a TOML fileCLI, method.kindRecipes §1, input reference
An ion, radical or metal center[molecule] charge, multiplicity (Python: on Molecule.from_xyz)Recipes §2
An optimized geometrymethod.task = "optimize"Recipes §3
Harmonic frequenciesmethod.task = "frequencies", ferric.run_frequenciesFinite differences of analytic gradients; examples/water-frequencies.toml
A transition state and its reaction pathferric.run_saddle, ferric.run_irc (Python only, closed shell)Golden paths Step 5
A screen of many ligandstools.pipeline.run_funnelRecipes §4
A pose relaxation or binding energytools/active_site/Pipeline notes
A residue ranking for mutationpocket_charges + pocket_field, then QM/MMRecipes §5. It ranks hypotheses and doesn't design mutations.
QM/MM embeddingferric.QmmmSystem, ferric.run_qmmm, CLI [qmmm]QM/MM
Toxicity and liability flagspython -m tools.toxToxicity screening
A machine-readable record of a run<input>.ferric.jsonl, written by defaultRun logs
Python instead of TOMLthe ferric modulePython API

Before you trust a number

Check the grade. Not every capability is equally validated. A method being available from the CLI doesn't mean its numbers are production-grade. Read its row in Capabilities and validation before quoting a value.

Check convergence, not the exit code. converged = true (CLI) or .converged (Python) is the signal. A run that hit max_iter still prints an energy. In the run log, read run_end.converged, and for correlated methods read the result record (Run logs).

When comparing to another code, check density fitting and the grid. Recipes §6 covers both.

Failure modes that cost the most time

  1. Charge/multiplicity parity. An odd electron count needs an even multiplicity. The error message prints the arithmetic and the rule, and it means your .xyz or your charge is wrong. ferric can handle ions.
  2. Debug instead of release. A debug binary is far slower. MEASURED: a 27-atom cation didn't converge a single point in 34 minutes on one. If a job seems hung, check which binary is running. cargo run --release --bin ferric and the wheel are both release builds. ./target/debug/ferric is not.
  3. MPI is a workspace feature, not a CLI one. ferric-cli has no mpi feature of its own, so -p ferric-cli --features mpi fails. The working build is cargo build --release --workspace --features mpi.
  4. Threading. Don't set OPENBLAS_NUM_THREADS above 1. The CLI and import ferric pin OpenBLAS to one thread when the variable is unset and honour an explicit value, and cargo sets it to 1 only when it is unset (an exported value wins). Multithreaded BLAS under ferric's rayon parallelism can crash (LU routines) or oversubscribe the machine. It's a correctness setting, not a performance tip.
  5. Under-converged density. energy_conv alone doesn't converge the density. Correlation energies and properties inherit the full first-order density error. E_HF doesn't, because it's variational. Set density_conv.
  6. Serialize QM jobs. Two concurrent ferric runs don't just halve throughput. MEASURED: a pair of them pushed a single point that normally takes seconds past a 15-minute timeout. Each run wants all the cores and several GB. Run one at a time, or use the funnel, which times each tier.

Memory: the budget predicts, the cgroup enforces

ferric works out a memory budget from [memory] budget_gb, FERRIC_MEM_BUDGET_GB, or, if neither is set, 0.8 × available RAM. The CLI prints that budget at startup and installs it as one shared, debited pool for the whole process: the three-index RI tensors, the DFT grid's AO cache and the large tensors of the MP2, RPA, GW and CC methods reserve their bytes from it, so two allocations alive at the same time cannot each claim the whole budget. From Python, memory_budget_gb= sets the same per-allocation limits but installs no shared pool, so each check compares its own allocation with the whole budget. In both, an allocation that does not fit is spilled to disk, recomputed on demand, or refused with an error naming it, depending on the allocation (see Sharp bits).

The budget isn't a cap on process memory (RSS). It covers the dominant tensors, not every allocation. Basis-sized matrices, integral engines (for example libint2's C++-side pools), BLAS and per-thread scratch and allocator overhead aren't charged, so treat the printed number as a floor on what the process needs. Lowering FERRIC_MEM_BUDGET_GB changes how the charged work is split into blocks. It doesn't shrink the uncharged part.

Size the budget from a gradient, not a single point. MEASURED on benzene/cc-pVDZ/PBE/RI-J: the SCF alone peaked at 0.616 GB, and the SCF plus gradient at 3.033 GB. The KS gradient holds the AO second derivatives on the grid. A budget sized from an energy run under-provisions a geometry optimization by roughly 4x, and the first optimization step is where it fails.

The cgroup is the only hard ceiling. On Linux with systemd:

scripts/ferric-limited --max=8G --high=7G -- ferric input.toml

An unbounded overshoot triggers the system-wide OOM killer, which can kill unrelated processes. Inside a cgroup, only the job dies. dmesg | grep -i oom confirms a kill. A truncated log doesn't prove one. As a rule of thumb, plan on ~6 GB for 27 atoms at def2-SVP.

Telling a running job from a dead one

  • Look for evidence of work, not a process name. pgrep -f also matches your own grep, a bash -c wrapper, or a defunct entry at 0% CPU. Sort by CPU instead: ps -eo pid,stat,pcpu,rss,etimes,comm --sort=-pcpu | head.
  • A log that has stopped growing doesn't mean the job is dead. stdout is buffered, so a long QM run can legitimately print nothing for tens of minutes. The run log (Run logs) is flushed record by record, so watch it instead.
  • Terminated usually means timeout(1), not the OOM killer. Check whether a timeout wrapped the command before you look in dmesg.
  • A grep filter that matches nothing looks exactly like "the job produced nothing". Test the filter against known-good output first.

Honest scope

ferric is a quantum chemistry engine: energies, gradients, finite-difference Hessians and properties. It isn't a protein-engineering tool, a docking program or an MD code. The tools/ layer connects it to RDKit, xtb and docking for screening, but the QM is the product.

Its analytic Hessian covers RHF and UHF only (other frequencies are finite differences of the gradient), and it has no thermochemistry (entropy, enthalpy, free energy). Transition-state search and IRC are Python-only and closed-shell only (Golden paths Step 5). If a task asks for something ferric can't do, such as designing mutations or predicting ΔΔG, say so instead of approximating it with something adjacent.

Methods overview

What exists, grouped by family. Maturity varies a great deal between these. Check the grade and its anchors on Capabilities and validation before trusting a number. To go from a task to a method, start with Choosing a method.

FamilyMethodsPage
SCF and DFTRHF, UHF, ROHF; KS-DFT (LDA, GGA, hybrid, range-separated, VV10, SCAN/r2SCAN); D3(BJ) and MBD@rsSCS dispersion; IEF-PCM and COSMO solvation; gradients, optimization, finite-difference frequencies, TS search and IRCSCF and DFT
MP2RI-MP2, attenuated (erfc, terfc), SCS, SCS-MP2(2terfc), MP2-V, RS-MP2 + LR-RPA, OO-RI-MP2, MP3, Laplace MP2 and SOS-MP2; local approximation of RI-MP2The MP2 family
Coupled clusterCCD, CCSD, CCSD(T), LinLCCD (hh, drivers-only, full; exact or local); double hybrids B2PLYP, DSD-PBEP86, ωB97X-L-VCoupled cluster
ResponsePDEP-RPA, dRPA by the Riccati solve (exact or local), G0W0, COHSEX, evGW0, evGW, BSE-TDA, TDA/TDDFT, polarizabilities and \( C_6 \)RPA, GW and excited states
Electron transfercDFT, \( H_{ab} \) couplings (Python and Rust, no CLI)Constrained DFT

QM/MM embedding is on its own page: QM/MM.

Exact and local correlation

A correlated calculation is two choices: the method (what is computed: RI-MP2, dRPA, LinLCCD) and the approximation (whether, and how, its amplitudes are truncated). ferric keeps them apart. method.kind (and the Python function) names the method, and the method is computed exactly. A local approximation is asked for explicitly, with the CLI's [local] section or the Python local= and eps= keywords, and only rimp2, drpa and linlccd have one.

The one local scheme is the amplitude threshold: in the Boys-localized basis, a pair amplitude whose localized integral is at or below eps is dropped (single threshold, Wang et al. 2023). Three facts govern its use:

  • eps is part of the model. It has no default and must be written, and every printout and run-log record of a local run carries it, with the fraction of amplitudes kept ("local": null marks an exact run). Report it with any number you quote.
  • eps = 0 is the exact method. It keeps every amplitude and reproduces the exact energy (RI-MP2 to ≤ 1e-9 Ha; dRPA to the canonical plasmon formula; LinLCCD of the same variant), which is how each local path is anchored.
  • The error is one-sided and grows faster than linearly in eps: dropped amplitudes give less correlation energy, measured with the same sign at every point, and each decade of eps raises the error by more than a decade (the fraction of amplitudes dropped grows with it). MP2 has Hylleraas stationarity; dRPA does not, so its error is first order in the dropped amplitudes and the one-sidedness is measured, not guaranteed.

Riccati, plasmon and PDEP are not approximations of each other: they are algorithms for the same exact dRPA. The drCCD Riccati solve at eps = 0, the plasmon formula and full-rank PDEP agree to ≤ 2.6e-14 Ha (H2/STO-3G and water/6-31G, PDEP at 64 quadrature points). Which one to use is a cost question: see RPA, GW and excited states.

Formal scaling with system size N, for orientation (these are the textbook exponents, not measured timings): SCF and DFT N⁴ for exact four-centre integrals, lower with density fitting and screening; RI-MP2 and its variants N⁵; CCSD N⁶; (T) N⁷.

Infrastructure

Shared machinery underneath all of the above:

  • Screening: Schwarz bounds on every four-centre path, CSB/CSAM (Thompson & Ochsenfeld 2017) as options, LinK exchange (Ochsenfeld, White & Head-Gordon 1998). QQR (Maurer, Lambrecht & Ochsenfeld 2012) is implemented and validated as a bound but not used in production.
  • Spherical and Cartesian basis support, with BSE-JSON and Gaussian-94 parsers and a set of bundled orbital, RI, JK and ECP bases.
  • einsum!: a tensor-contraction macro that routes contractions through BLAS3 GEMMs, used throughout the CC and MP3 code.
  • Memory budgets: allocation ceilings that refuse an oversized job with a named breakdown instead of letting it be OOM-killed.
  • Python bindings (pyo3) and a TOML-driven CLI. Not every method has both; Capabilities and validation says which.

Properties

ESP at nuclei and on the molecular surface, electric field, static and atom-partitioned polarizabilities, Mulliken, Löwdin, Hirshfeld, CHELPG and RESP charges, density matrices, and NPZ export of ML-ready features.

Negative results

Known limits and measured negatives are kept in one place, Capabilities and validation: known limits, so a correction has one row to change. Two are worth knowing before you pick a method:

  • TDHF/RPAx \( C_6 \) stays about 63% low regardless of gap. Use that kernel for static polarizabilities only.
  • Local MP2: the integral-direct local MP2 (rimp2 with [local] integral_direct = true) is measured sub-quadratic (about N1.24 erfc, N1.4 Coulomb) on alkanes C20–C48; it is at about parity with exact RI-MP2 at C20 and about 6× faster at C32. Local MP2 without integral_direct makes no scaling claim. See The MP2 family.

The AO-sparse Laplace SOS-MP2 truncation is not a negative: the radius it needs does not track the molecular diameter. See the MP2 page.

SCF and DFT

Ground-state self-consistent field methods, their nuclear gradients, and the things built on them: geometry optimization, harmonic frequencies, transition-state search, implicit solvation and dispersion correction. The [scf] and [dft] keys are in Input file; grades are on Capabilities and validation.

Hartree–Fock

What it is. RHF (closed shell), UHF and ROHF (open shell), with DIIS, Schwarz screening, and a choice of direct, LinK, density-fitted (RI-J / RI-K) or seminumerical (COSX) Fock builds (see Choosing how exchange is built below). The open-shell solvers add per-spin DIIS, a virtual-block level shift, augmented-Hessian Newton, and Maximum-Overlap-Method (MOM) orbital tracking for near-degenerate cases (Gilbert, Besley & Gill 2008). These exist because specific systems failed without them.

Run it. method.kind = "rhf" / "uhf" / "rohf" (examples/water-rhf.toml, examples/h_uhf.toml); Python ferric.run_rhf, run_uhf, run_rohf. Spin and charge are set on the molecule (Molecule.from_xyz(path, charge, multiplicity)), not on the solver call.

Defaults. Exact four-index J and K (no density fitting) unless [scf] df_j_aux / df_k_aux are set. Convergence gates on the density: density_conv = 1e-6 by default; energy_conv (default 1e-3) is a sanity bound, not the convergence target.

Accuracy. Proven (rhf, uhf, rohf).

Kohn–Sham DFT

What it is. Kohn–Sham DFT through libxc: LDA, GGA, hybrid and range-separated hybrid functionals (for example PBE, B3LYP, ωB97X-V), VV10 nonlocal correlation, and the SCAN / r2SCAN meta-GGAs (τ-dependent; no density Laplacian). The solvers cover RKS, UKS and ROKS.

Run it. method.kind = "ksdft" with [dft] functional = "PBE" (examples/water-wb97xv.toml, examples/benzene-dfb3lyp.toml); Python ferric.run_dft or run_ksdft. The two Python entry points are closed shell (RKS) only. In the CLI, ksdft on a molecule with multiplicity > 1 runs UKS, and kind = "uhf"/"rohf" with [dft] functional run UKS/ROKS. Open-shell KS is also the reference inside pdep-rpa and gw ([rpa] xc with multiplicity > 1), and in Python through run_frequencies(reference="uhf", xc=...) and QM/MM (run_qmmm(method="uks")); see Capabilities and validation.

Defaults.

  • Functional: LDA if [dft] functional is omitted.
  • Grid: Becke partitioning of atom-centred Treutler–Ahlrichs radial × Lebedev angular grids, 75 × 110 points per atom, unpruned.
  • Pruning is opt-in: [dft] grid_prune = "nwchem" (examples/water-pbe-pruned-grid.toml) removes about 23% of points at 75 × 110. It is accepted for task = "energy" only; optimization and frequency runs refuse a pruned grid because the gradient's grid-response term is built on the unpruned grid.
  • Density fitting: RI-J and RI-K are on by default for ksdft and run_dft (def2-universal-jkfit), while rhf is exact by default. In Python, df_j_aux="" selects exact Coulomb. The fitting error grows with size: measured at PBE/STO-3G against exact J it was 0.28 kcal/mol for water, 1.16 for benzene and 9.5 for a 71-atom drug molecule. Compare with an exact-Coulomb code only after turning fitting off, or at matched fitting.
  • Meta-GGAs get an automatic 0.5 Ha virtual-block level shift (ramped to zero at convergence) when you set none, because τ amplifies grid noise and plain DIIS limit-cycles.

Accuracy. ksdft is Proven. SCAN/r2SCAN energies are pinned against PySCF (ferric-scf/tests/dft_scan.rs); their closed-shell gradients against PySCF with grid response (dft_gradient_mgga.rs). Other meta-GGA variants (deorbitalized, +VV10, hybrid meta-GGA) are refused, not approximated.

Dispersion correction: D3(BJ)

What it is. Grimme's D3 with Becke–Johnson damping, a post-SCF pairwise correction, implemented natively in Rust. The two-body term only; the three-body (ATM) term is not implemented.

Run it. [dft] dispersion = "d3bj" on method.kind = "ksdft" (examples/water-pbe-d3bj.toml); "d3bj(b3lyp)" borrows another functional's fitted damping parameters. Python: run_dft(..., dispersion="d3bj"), or ferric.d3bj_energy(mol, functional) for the correction alone.

Scope. The damping parameters are fitted per functional, so the key is refused on any method other than ksdft. task = "optimize" works (the D3 gradient is implemented; ferric-d3/tests/gradient_vs_fd.rs), and so does closed-shell task = "frequencies": see Frequencies with dispersion.

Accuracy. Agrees with simple-dftd3 1.6.0 to below 1e-12 Ha on water, methane, benzene and an argon dimer with the three-body term off on both sides (ferric-d3/tests/vs_reference_dftd3.rs). The omitted three-body term is 0.1% of the two-body energy at benzene and grows with size.

Dispersion correction: MBD@rsSCS

What it is. The many-body dispersion energy of Ambrosetti et al. 2014 (range-separated self-consistent screening), added post-SCF. Its per-atom inputs are Tkatchenko–Scheffler free-atom α, C6 and R_vdW scaled by Hirshfeld volume ratios v_A / v_A^free of the converged SCF density; the free-atom volumes come from live free-atom SCFs in the same basis and SCF settings, solved for the isolated atom: point charges, external fields, implicit solvent, polarizable sites and cDFT constraints of the molecular run are not applied to it. The model itself is described under Polarizabilities and dispersion coefficients.

Run it. [dft] dispersion = "mbd" on a Kohn–Sham SCF (examples/water-pbe-mbd.toml) uses the β published for [dft] functional (PBE 0.83, PBE0 0.85, HSE06 0.85); "mbd(pbe0)" uses another functional's β. Any other functional is an error, not a default β. The printout gives E(KS-DFT), E(MBD@rsSCS) with β and the functional, and the corrected total; the JSON run log's run_end record carries the corrected total as energy, with scf_energy and a dispersion object (model, params, energy, beta, volume_ratios) in extra (D3(BJ) runs log the same object without beta and volume_ratios). UKS/ROKS energy runs, which write no run_end record, write a dispersion record with the same fields. Python: run_dft(..., dispersion="mbd"), which also reports DftResult.volume_ratios.

Gradient. task = "optimize" on an RKS, UKS or ROKS reference and run_dft(with_gradient=True) add the exact analytic MBD@rsSCS gradient: the explicit dependence on the nuclear positions, and the dependence through the Hirshfeld volumes, including how the SCF density itself responds to the displacement. That response is the orbital relaxation, obtained from one coupled-perturbed Kohn–Sham (Z-vector) solve per gradient; it costs about as much as a few SCF iterations. On a UKS reference the solve is coupled across the α and β orbital rotations (Coulomb couples the spins; exchange and the XC kernel are spin-resolved), and the orthonormality of each spin's occupied orbitals enters separately. On a ROKS reference the solve runs over the three rotation blocks of the shared orbitals (closed→virtual, open→virtual, closed→open) with the exact restricted-open-shell orbital Hessian, and the closed and open orbitals are kept orthonormal as one set, which adds a closed–open cross term to the orthonormality contribution. The volumes are integrated on a lattice anchored to the molecular centroid, and its motion is part of the gradient. Against finite differences of the full SCF + MBD calculation at 6-31G the gradient agrees to 6e-9 Hartree/Bohr for H2O with PBE, PBE0, HSE06 and PBE with RI-J, and to 9e-10 for NH3; the orbital relaxation alone is 1.0e-5 for H2O (11.5% of the largest MBD component). For UKS doublets and a triplet (NH2, OH, O2 at 6-31G with PBE, PBE0 and HSE06) it agrees to ≤ 1.9e-9, and to 6e-9 for OH with PBE + RI-J and PBE0 + RI-JK, against an orbital relaxation of 3e-6–1e-5. For ROKS doublets and triplets (HCO, NH2, CH2, O2 at 6-31G) it agrees to ≤ 2.1e-11 with PBE and PBE0, ≤ 4.5e-10 with HSE06 and 7.5e-9 for HCO with PBE + RI-J, against an orbital relaxation of 2.7e-6–9.8e-6. The relaxation term needs an LDA, GGA or hybrid-GGA functional without VV10, no implicit solvation, polarizable embedding or cDFT constraints, and integer aufbau occupation (no MOM); UKS and ROKS additionally need exchange that is not COSX. Other setups are refused rather than given an approximate gradient. Open-shell frequencies with dispersion are refused. Python's run_dft is closed-shell only.

Frequencies with dispersion

What it is. Harmonic frequencies on the dispersion-corrected surface E(KS) + E(disp), closed-shell Kohn–Sham only. The Hessian is the central difference of the corrected analytic gradient: at each of the 6N displaced geometries the SCF is converged, the KS gradient and the dispersion gradient are evaluated there, and their sum is differenced. For MBD@rsSCS the dispersion gradient is the exact one above, so the response of the Hirshfeld volumes to the displacement, through the SCF density, is in the Hessian. There is no analytic Hessian on this path: [frequencies] hessian = "analytic" is an error and "auto" runs finite differences.

Run it. [dft] dispersion with method.task = "frequencies" on a closed-shell KS SCF. The printout gives the corrected energy, E(KS-DFT) and the dispersion energy at the input geometry, and the JSON run log gets a dispersion record with the same fields as an energy run. Python: run_frequencies(mol, basis, xc="PBE", dispersion="d3bj"), which reports .e_dispersion and the corrected .energy. Open-shell (UKS/ROKS) frequencies with dispersion are refused.

Accuracy. For PBE/STO-3G water, the D3(BJ) part of the Hessian agrees with 4-point second differences of the D3(BJ) energy to 5.3e-9 Hartree/Bohr² (largest element 1.9e-5), and the resulting frequencies with those of the KS Hessian plus that independent D3 Hessian to 1e-5 cm⁻¹. The MBD@rsSCS part agrees along three fixed directions with second differences of the full SCF + MBD energy to 1.5e-6 Hartree/Bohr² (0.8% of the largest curvature, 2.0e-4). That figure is set by noise in the energy differences, not by the Hessian construction. A gradient without the orbital-relaxation term misses by 3.2e-5. Dispersion leaves the Hessian's asymmetry (7.6e-6 Hartree/Bohr² for PBE/6-31G water) and the projected translation/rotation modes (below 1e-4 cm⁻¹) where they are without it.

The shifts are small for a single molecule. For PBE/6-31G water at its uncorrected PBE minimum (1594.35, 3500.27 and 3669.29 cm⁻¹), D3(BJ) shifts the three modes by +0.006, −0.077 and −0.095 cm⁻¹, and MBD@rsSCS by +0.107, −0.406 and −0.516 cm⁻¹.

Implicit solvation

Two independent implementations. Both are threaded uniformly through RHF, UHF, ROHF and the KS variants, and both leave the energy bit-identical to vacuum when unset.

ModelWhat it isHow to run itAnchor
IEF-PCM (ferric-pcm)Integral-equation PCM; modified Bondi radii (H 1.10 Å), atom-centred Lebedev spheresCLI [pcm] solvent = "water" (or epsilon), energy runs of rhf, uhf, rohf, ksdft and pdep-rpa (examples/water-pcm.toml). Python run_rhf(..., solvent=78.4) or a solvent name; also run_pdep_rpa.water / STO-3G, ε = 78.4: −3.813 vs PySCF IEF-PCM −3.8228 kcal/mol
COSMO (ferric_scf::cosmo)Conductor-like screeningCLI [cosmo] epsilon = 78.39water / cc-pVDZ, ε = 78.39: −5.955 vs PySCF COSMO −5.94 kcal/mol (ferric-scf/tests/cosmo_water.rs)

Neither has cavitation, dispersion or repulsion terms, and neither has an analytic gradient. An unknown solvent name is an error, not a silent vacuum.

Gradients, optimization, frequencies, transition states

Gradients. Analytic nuclear gradients for RHF, UHF, ROHF and KS-DFT (including meta-GGA, with grid response), checked against finite differences and, for DFT, against PySCF. RI-MP2 also has an analytic gradient, closed shell only (there is no unrestricted MP2 nuclear gradient).

Optimization. method.task = "optimize" for rhf, uhf, rohf, ksdft, rimp2 and pdep-rpa (examples/h2_opt.toml, examples/h2-lda-opt.toml); on an open-shell molecule only uhf, rohf and ksdft. Python ferric.run_optimize is RHF.

Harmonic frequencies. method.task = "frequencies" for rhf, uhf, rohf and ksdft (examples/water-frequencies.toml); Python ferric.run_frequencies(mol, basis_name, reference="rhf", xc=...). Mass-weighted, translations and rotations projected out. The Hessian is:

  • Analytic for closed-shell RHF, and for UHF of any multiplicity, with exact four-centre J/K, no ECP, no external potential or solvent, and a basis up to f functions: one SCF plus a coupled-perturbed HF solve per nuclear coordinate (for UHF, one solve coupling the α and β orbital rotations). This needs a libint2 with second derivatives (the build scripts/install-libint.sh installs).
  • Central finite differences of the analytic gradient everywhere else (ROHF, KS-DFT, RI J/K, ECPs, embedding, g functions): 6N gradient evaluations. The displacement [frequencies] delta (default 5e-3 Bohr) is a real accuracy knob; the printed Hessian asymmetry, zero in exact arithmetic, is the check that it and the SCF thresholds suit the system.

[frequencies] hessian (Python hessian=) selects it: "auto" (default, analytic where it applies), "analytic" (an error where it does not) or "fd". The output names the one that ran (Hessian = analytic; Python .hessian_source).

Transition states and IRC (Python only, closed shell). ferric.run_saddle(mol, basis_name, xc=...) searches for a first-order saddle by P-RFO, using two finite-difference Hessians with Bofill updates between them; it costs 2(6N + 1) + (n_steps + 1) gradient evaluations and refuses to start from a geometry with no negative mode. result.is_transition_state() requires both convergence and exactly one imaginary frequency. ferric.run_irc(mol, basis_name, result.imaginary_mode) then follows the reaction path both ways to show which minima the saddle connects (measured about 71 gradients per branch on NH3 inversion). Both refuse multiplicity ≠ 1.

Choosing how exchange is built

Four ways to build K, and the choice is a real one, measured on this code. Butane, one thread; TZ is def2-TZVP (184 functions), QZ is def2-QZVP (528).

[scf] settingWhat it isExact?Scope
(default)Schwarz-screened direct four-centre J + Kyesall SCF types
k_builder = "link"LinK: pair-list-screened direct Kyes (== direct to 9e-12 Ha, butane/def2-SVP)RHF, UHF, ROHF
df_j_aux / df_k_auxdensity-fitted J and K (RI-JK)fitting error, grows with size (see Kohn–Sham DFT above)all SCF types
k_builder = "cosx"seminumerical (COSX) K on a grid; with RI-J active this is RIJCOSXgrid-dependent error, see belowRHF/UHF/ROHF and their Kohn–Sham variants, Coulomb operator only; analytic gradient for RHF, RKS, UHF (see below)

Time for one exchange build (seconds, butane, one thread):

BuilderWhat the time coversdef2-TZVPdef2-QZVP
directJ and K together (one integral sweep)not measured400
LinKKbeing re-measuredbeing re-measured
RI-JKK0.050.43
COSXKnot measured90

Time for a full SCF (seconds, butane, one thread):

Builderdef2-TZVPdef2-QZVP
direct98not measured
LinKbeing re-measuredbeing re-measured
RI-JKnot measurednot measured
COSX358not measured

Energy error against exact exchange:

BuilderSystem / basisError
LinKbutane / def2-SVP9e-12 Ha
COSX, default (sgx (35,194) + final pass on sgx (50,302))water / aug-cc-pVDZ−8.1e-9 Ha
COSX, defaultbutane / def2-SVP−3.4e-5 Ha
COSX, defaultbutane / def2-TZVP−4.3e-6 Ha
COSX, flat (50,110)water / cc-pVDZ5e-6 Ha
COSX, flat (50,110)butane / def2-SVP1.7e-4 Ha
COSX, flat (50,110)butane / def2-TZVP1.2e-4 Ha
RI-JK—not measured on these systems

Start with density fitting whenever its three-index tensor fits in memory (n_aux × n_bf² × 8 bytes: 1 GB for butane/QZVP, 7 GB for octane/QZVP). It spills to disk when it does not. It is two to three orders of magnitude faster per build than anything else here. Its error is a fitting error, not zero, and it grows with system size.

Need exact exchange? Direct or LinK; both are exact to the screening threshold as K builders (butane/def2-SVP: link == direct to 9e-12 Ha). LinK's cost is being re-measured, so no LinK timing is quoted here. k_builder is honoured by UHF and ROHF as well as RHF: the open-shell solvers build K_α and K_β from one builder instance, refreshing its density-dependent state per spin. Whether LinK is the faster choice for a given system is a separate question from whether it is honoured — see the cost note above, which is being re-measured. link is skipped with a warning, never silently, whenever density-fitted J/K is active; both link and cosx are skipped with a warning when the functional uses no exact exchange or is range-separated (exchange then comes from the SR/LR fitters).

RIJCOSX. k_builder = "cosx" combines with density-fitted Coulomb: when RI-J is active (df_j_aux named, or the Kohn–Sham default), J comes from RI-J and K from COSX, as in ORCA's RIJCOSX and Psi4's DFDIRJ+COSX. COSX then replaces RI-K: the Kohn–Sham RI-K default is not applied, and an explicitly named df_k_aux next to k_builder = "cosx" is an error. df_j_aux = "" keeps exact J with COSX K. The two approximations add: on water/6-31G (HF) the COSX error is 7.140e-6 Ha with exact J and 7.141e-6 Ha with RI-J, and the cross term is second order (2.8–5.8 × the product of the two errors; 7.8e-10 Ha at the (50,110) grid, 2.3e-11 Ha at (75,302)). Gradients take J from the RI-J derivative and K from the COSX derivative (FD agreement 1.7e-9 Ha/Bohr RHF, 3.6e-9 UHF).

COSX is for large basis sets on systems too big for RI-JK. Its cost per grid point barely moves with angular momentum while analytic exchange grows roughly tenfold from SVP to QZVP, so it wins at high angular momentum, not at large system size. Measured on one thread at the flat (50,110) grid:

  • On butane, against exact direct exchange: at def2-TZVP the full COSX SCF is 3.7× slower (358 s vs 98 s); at def2-QZVP the COSX K build takes 90 s, against about 400 s for the direct build's single J+K sweep, at a relative K error of 5.4e-5.
  • On n-alkanes at def2-SVP, against LinK: slower at every size measured, 1.59× (C20), 1.09× (C32) and 1.22× (C48), with no trend toward parity. At def2-TZVP on C20 it is faster (0.67×).

Below quadruple zeta it is the wrong tool unless RI-JK's three-index tensor does not fit in memory.

A density-driven pair screen (on the product of the integral bound and the local half-transformed density) keeps the K error below 2e-6 Ha at the default threshold, and the half-transforms D·X and X·Gᵀ run over per-batch sparse AO lists (cosx_half_transform = "sparse", the default). At def2-SVP the K build grows as N^1.29–N^1.32 between C20 and C48; that is one family of molecules in one basis.

On the flat (50,110) grid COSX's energy error is about 5e-6 Ha on water/cc-pVDZ (4.9e-6 in cosx_k_anchors.rs, 5.1e-6 in the ORCA comparison run), 1.7e-4 Ha on butane/def2-SVP and 1.2e-4 Ha on butane/def2-TZVP (against exact exchange). ORCA 6.1.1's COSX at its own default grid gives 4.9e-6, 3.8e-5 and 1.3e-5 Ha on the same systems and bases with about half as many points: ORCA's grids are pruned around a 194-point valence shell and it evaluates its final energy once on a finer grid. ferric's default does the same (cosx_grid pruning and cosx_final_pass, below). Refining the flat grid converges water to 3.4e-8 Ha, but butane/def2-SVP stays at 3.4e-5 Ha at Lebedev-302 for every radial grid. That residual is angular: an independent COSX (PySCF SGX, 75 radial shells) on the same system goes from 5.5e-5 Ha at 302 points per shell to -8.5e-6 at 434 and 2.7e-6 at 590. ferric's COSX follows the same path at 75 radial shells: -3.4e-5 at 302, -5.6e-6 at 434 and +1.8e-6 at 590, for 1.8x and 2.2x the 302-point wall time. Reaction energies cancel most of the error (0.02 kcal/mol on an isodesmic alkane reaction at the flat (50,110) grid); absolute energies do not. Four knobs, all optional:

  • cosx_grid = { radial = 35, angular = 194, prune = "sgx" } is the default; { radial = 50, angular = 110 } is the flat grid. The angular order matters most: on butane/def2-TZVP the error falls from 1.2e-4 Ha at 110 points per shell to 4.0e-6 Ha at 302, while going from 50 to 100 radial shells changes it by 1e-6 or less. Cost grows with the number of points. angular must be one of 6, 14, 26, 50, 110, 194, 302, 434 or 590. prune = "sgx" prunes the grid the way ORCA's GridX and PySCF's SGX do: five radial regions per atom (NWChem boundaries on Bragg radii) with Lebedev orders from one row, picked by the peak angular — at 194 the regions get 26/50/110/194/110 points. ferric's pruned COSX energy equals PySCF SGX on the same grid to 1.8e-12 Ha. A table without prune is flat.
  • cosx_final_pass = true (the default; false turns it off) re-evaluates exchange once on a larger grid at the converged density and reports that energy; the SCF-grid energy is printed and logged next to it. cosx_final_grid picks the grid (default { radial = 50, angular = 302, prune = "sgx" }). The pass is not self-consistent, but it lands within 1.5e-7 Ha of an SCF converged on the final grid (table below). It is energy-only: gradients and geometry tasks run without it.
  • cosx_overlap_fit = true (default) applies the Izsák–Neese overlap correction. At the default grid it helps; on coarser grids it makes things worse, and its benefit is strongly molecule-dependent — large on water, nil to negative on ethane — so do not expect the factor quoted in the literature.
  • cosx_backend = "md3c1e" (default) is the batched McMurchie–Davidson integral kernel. "cosx-a" is the per-point libint2 path, about three times slower and kept only as the cross-check the kernel is anchored against.
  • cosx_screen_thresh = 1e-7 (default) is the density-driven pair-screening threshold. 0.0 disables screening bit-identically; 1e-6 already fails a 1e-6 Ha K-error bar on butane. Not available with the "cosx-a" backend.

Setting any cosx_* key without k_builder = "cosx", or k_builder = "cosx" together with a named df_k_aux, is an error.

Which COSX grid. Measured against exact exchange (exact J on both sides, RHF, overlap fit on unless noted), one run each, six threads; times are whole SCFs, indicative only. Errors, E − E_exact in Ha:

Systematomsflat (50,110)sgx (35,194)sgx (35,194) + final sgx (50,302), defaultsgx (50,302)sgx (50,194)flat (50,194)sgx (35,194), no fit
water / aug-cc-pVDZ3+6.1e-6+1.9e-6−8.1e-9−8.1e-9+9.8e-7+1.6e-7+1.6e-6
butane / def2-SVP14+1.7e-4+4.6e-5−3.4e-5−3.4e-5+4.1e-5+5.0e-5−2.7e-4
butane / def2-TZVP14−1.2e-4−4.1e-5−4.3e-6−4.3e-6−3.2e-5−1.6e-5−1.2e-4
benzene / def2-SVP12+8.2e-5−4.8e-5+5.6e-6+5.6e-6−4.8e-5−4.0e-5−8.8e-5
sulfamethoxazole / def2-SVP28+2.5e-4−1.9e-5+2.2e-5+2.1e-5−2.3e-5−2.3e-5−1.9e-4
methane / cc-pVDZ5+8.0e-6+9.2e-6+6.5e-7+6.5e-7+1.0e-5+5.1e-6−1.9e-4
ethane / cc-pVDZ8+1.2e-4−4.3e-6−2.6e-6−2.6e-6−4.5e-6+1.7e-6−9.1e-5
propane / cc-pVDZ11+1.2e-4+1.9e-5−1.0e-5−1.0e-5+1.8e-5+1.8e-5−7.0e-5
C3H8 + CH4 → 2 C2H6, kcal/mol+0.074−0.023+0.003+0.003−0.024−0.012+0.052
points per atom55003545–36293545–3629 SCF, 8109–8214 final8109–82144996–506897003545–3629

Whole-SCF wall time relative to exact-K RHF on the same system (COSX is slower than exact exchange at all of these sizes and bases; see above for where it wins):

Systemexact Kflat (50,110)sgx (35,194)sgx (35,194) + finalsgx (50,302)
water / aug-cc-pVDZ0.3 s15.7×10.1×12.8×21.7×
butane / def2-SVP2.6 s17.7×11.8×14.3×25.5×
butane / def2-TZVP19.0 s5.4×3.9×4.4×7.5×
benzene / def2-SVP3.2 s13.2×8.8×10.6×18.9×
sulfamethoxazole / def2-SVP78.7 s7.2×5.9×6.5×14.4×
methane / cc-pVDZ0.2 s27.0×20.5×22.5×40.2×
ethane / cc-pVDZ1.0 s18.7×12.7×15.8×28.7×
propane / cc-pVDZ3.6 s13.8×9.7×11.0×20.8×

What the table shows:

  • The pruned sgx (35,194) grid uses 0.65× the points of flat (50,110) and is more accurate on seven of the eight molecules, by 1.7× (benzene) to 29× (ethane). On methane it is 1.15× worse (9.2e-6 against 8.0e-6 Ha). This is the grid geometry tasks (optimize, frequencies) use, since they run without the final pass.
  • The final pass on sgx (50,302) reproduces an SCF converged on that grid to 4e-8 Ha on seven molecules and 1.5e-7 Ha on sulfamethoxazole, at 0.45–0.59× of that SCF's cost. Together with the pruned SCF grid it is more accurate than flat (50,110) on all eight molecules and on the reaction (0.003 against 0.074 kcal/mol), and faster on all eight (0.80–0.91× flat (50,110)'s wall time). This pair is the default for energies; ROHF/ROKS has no final pass and skips it with a note.
  • More radial shells (35 → 50) at a 194 peak buy little; removing the pruning (flat 194) costs 2.7× the points for errors of the same size. The overlap fit helps on every molecule but water (1.8× to 21×; on water it is 1.2× worse).

COSX gradients.

task = "optimize" and task = "frequencies" with k_builder = "cosx" differentiate the COSX energy itself (grid-function, ESP-integral and Becke-weight derivatives). With the default overlap fit the fitted exchange is not variational in the orbitals, so the gradient adds an orbital-response (Z-vector) term. The gradient is exact for RHF, RKS and UHF with cosx_overlap_fit = false, and for RHF and UHF with the default fit; measured against finite differences of the COSX energy it agrees to 2e-9–4e-9 Ha/Bohr, on flat and pruned (sgx) grids alike, and with RI-J (RIJCOSX). The gradient differentiates the SCF-grid energy: with cosx_final_pass the reported energy is the final-grid one, so geometry tasks run without the pass and a gradient of a final-pass result is refused (an ORCA-style gradient evaluated on the final grid misses finite differences of the final-grid energy by 4.6e-7–7.3e-7 Ha/Bohr on water/6-31G, so it is not used). Fitted COSX with a Kohn–Sham functional, UKS and ROHF/ROKS are refused for gradient tasks before the SCF runs.

Convergence

A few things worth knowing before debugging a stubborn SCF:

Check converged. These routines return a result whether or not they converged; a non-converged SCF is a result with converged = false, not an error. Downstream code that ignores the flag will happily consume a half-converged density.

Multiple solutions are real. For systems like alkane chains, different initial guesses converge to genuinely different SCF solutions — not a convergence failure but a different basin. The guess picks the basin.

Density-fitting has a noise floor. DF-JK introduces an error floor that makes energy-based convergence criteria below roughly 1e-9 meaningless; SCF gates on the density RMS change instead.

Near-linear-dependence. Diffuse (aug-) basis sets on close-packed systems can drive the overlap matrix near-singular; the canonical-orthogonalization threshold is tunable via FERRIC_LINDEP_THRESH.

Screening

  • Schwarz bounds on every 4-centre path, built so they can never underestimate (zero-valued table entries are floored; left unfloored, one such entry was measured to cost 1.5e-4 Ha)
  • LinK (Ochsenfeld, White & Head-Gordon 1998) — exchange via significant-pair and density-pair lists; its scaling is being re-measured
  • QQR (Maurer, Lambrecht & Ochsenfeld 2012) is implemented and validated as a bound but is not used in production: on LinK it screened only 0.009% more quartets than Schwarz at alkane_16 (measured against a LinK pair-list implementation that skipped quartets; not yet repeated on the current lists)
  • COSX shell-pair screening uses a primitive-level Hölder bound that provably never underestimates; an overlap-based bound can underestimate, which silently corrupts K

QM/MM embedding

QM/MM (electrostatic, polarizable and Gaussian-smeared embedding, link atoms, boundary-charge schemes, and a [qmmm] CLI section that reads a PQR) has its own page: QM/MM, including its CLI section.

Determinism

The Fock build's reduction folds partial matrices in a strict ascending group order, independent of thread count and of the memory band width. Results are bit-identical across RAYON_NUM_THREADS — a property pinned by tests, not just intended.

This matters more than it might seem: a tree-fold reduction would be equally deterministic but would produce different bits, since floating-point addition is not associative. The ascending order is load-bearing.

Cite

DIIS: Pulay 1980. MOM: Gilbert, Besley & Gill 2008. LinK: Ochsenfeld, White & Head-Gordon 1998. COSX: Neese et al. 2009; overlap fit: Izsák & Neese 2011. CSB/CSAM screening: Thompson & Ochsenfeld 2017. DFT grids: Becke 1988, Treutler & Ahlrichs 1995, Lebedev & Laikov 1999. Functionals through libxc (Lehtola et al. 2018): PBE, B3LYP, ωB97X-V, SCAN, r2SCAN, VV10. D3(BJ): Grimme et al. 2010, Grimme, Ehrlich & Goerigk 2011. IEF-PCM: Cancès, Mennucci & Tomasi 1997; COSMO: Klamt & Schüürmann 1993. P-RFO: Banerjee et al. 1985; Bofill 1994. Full entries in References.

The MP2 family

The largest method family here. Every variant is density-fitted (RI): each needs an orbital basis and a matching RI auxiliary basis ([mp2] auxbasis, or the auxbasis argument in Python). Formal cost is O(N⁵) for the RI-MP2 transformation. Grades per method.kind are on Capabilities and validation. The [mp2] keys are in Input file.

RI-MP2

What it is. Second-order Møller–Plesset theory on an RHF reference (UHF for an open shell, below) with 3-centre/2-centre density fitting. Canonical (non-RI) MP2 is also implemented, for cross-validation, not production.

Open shells. rimp2 and oo-rimp2 accept an open-shell molecule (multiplicity > 1) from the CLI, for task = "energy" only: they solve the same plain UHF that kind = "uhf" runs, then take unrestricted RI-MP2 (UMP2, as PySCF mp.MP2(uhf)) or unrestricted OO-RI-MP2. There is no unrestricted MP2 nuclear gradient, and [mp2] kappa is refused on an open shell. From Python, run_rimp2 does the same (UHF + UMP2 when multiplicity > 1; result.reference says which, and kappa raises); run_oo_rimp2 is closed shell only. Both unrestricted methods are validated on the CH3 doublet at cc-pVDZ: U-RI-MP2 against ORCA RI-MP2 NoRI, U-OO-RI-MP2 against an independent numpy OO-RI-MP2 (see the anchors).

The other MP2-family kinds (mp3, att-rimp2, scs-mp2, scs-mp2-2terfc, laplace-mp2, laplace-sos-mp2, rs-mp2-rpa) and their Python drivers are closed shell only: set multiplicity = 1. mp2-v switches to a UHF reference when multiplicity > 1 (CLI, energy only); Python run_mp2_v is closed shell only.

Run it. method.kind = "rimp2" (examples/water-rimp2.toml, examples/water-rimp2-frozen-core.toml); Python ferric.run_rimp2(mol, bs, aux).

Accuracy. Proven. RI-MP2 is size-extensive to 2e-12 Ha for a well-separated dimer against twice the monomer (ferric-mp2/tests/rimp2_size_extensivity.rs, which asserts 1e-7).

Knobs. frozen_core defaults to 0 (all electrons correlated). Several published parameterizations below assume frozen core, so set it when you use them.

Attenuated MP2

What it is. MP2 with the Coulomb operator in the correlation energy replaced by a short-range one. Two forms are implemented:

  • erfc: \( \mathrm{erfc}(\omega r)/r \), which is \( 1/r \) at short range and decays to zero beyond roughly \( 1/\omega \).
  • terfc: a short-range operator that is Coulombic inside a cutoff radius \( r_0 \) and attenuated outside it, with a controlled curvature (Dutoi & Head-Gordon 2008). It needs precomputed interpolation tables (FERRIC_TERF_TABLE_DIR, generated under terf-tables/); without them the run errors rather than substituting another operator.

Why. MP2's error for non-covalent interactions has two parts that partly cancel in small basis sets: basis-set superposition error, and an error in the long-range part of the correlation energy. Attenuation removes the long-range part. That also means attenuated MP2 has no long-range dispersion at all: its asymptotic \( C_6 \) is zero. The method works in the basis and on the systems it was fitted for; MP2-V (below) adds long-range dispersion back through VV10.

Parameterization. The attenuation parameters are fitted, not derived, and each fit belongs to one basis. The aug-cc-pVTZ sets that ferric ships as defaults were fitted on S66 without counterpoise and with frozen core (stated in crates/ferric-mp2/src/scs.rs):

VariantParametersBasis of the fitSource
erfcω; ferric's default is 0.420 Å⁻¹, recorded in the code as the dissertation's erfc optimumaug-cc-pVDZ in the 2012 paperGoldey & Head-Gordon 2012
terfcone cutoff \( r_0 \)aug-cc-pVTZGoldey, Dutoi & Head-Gordon 2013
SCS-MP2(2terfc)\( r_0(1) \) = 0.75 Å, \( r_0(2) \) = 1.05 Å, cOS = 1.27, cSS = 4.05aug-cc-pVTZ, no counterpoise, frozen coreGoldey & Head-Gordon 2014
MP2-V\( r_0 \) = 1.00 Å, b = 11.0, C = 0.0089, terfcaug-cc-pVTZ, no counterpoise, frozen coreGoldey, Belzunces & Head-Gordon 2015

Running a fitted variant in another basis, with counterpoise, or with frozen_core = 0 (the default) is extrapolation outside the fit.

Run it.

Variantmethod.kindExamplePython
erfcatt-rimp2examples/water-attmp2.tomlrun_attenuated_rimp2(..., omega=0.420) (Å⁻¹)
terfcatt-rimp2 with [mp2] att_operator = "terfc", att_r0 (Å)examples/water-attmp2-terfc.tomlrun_terfc_rimp2(..., r0=...)
SCS-MP2 (Grimme)scs-mp2examples/water-scs-mp2.tomlrun_scs_mp2(..., c_os=, c_ss=)
SCS-MP2(2terfc)scs-mp2-2terfcexamples/water-scs-mp2-2terfc.tomlrun_scs_mp2_2terfc(...)
MP2-Vmp2-vexamples/water-mp2v.tomlrun_mp2_v(...)

ω is in Å⁻¹ in the CLI and Python and in Bohr⁻¹ in the Rust API. The SCS-MP2 defaults are Grimme's cOS = 1.2, cSS = 0.333.

Accuracy. att-rimp2, scs-mp2 and scs-mp2-2terfc are Proven (the erfc energy is pinned against PySCF/libcint in testdata/reference/h2o_cc-pvdz_attenuated-rimp2-erfc0p420.json). mp2-v is Smoke: its VV10 half is bit-identical to the ωB97X-V code path, but there is no published MP2-V total energy to compare with, and ferric evaluates the VV10 term on the converged HF density (the post-HF variant) on its own 50×50 grid rather than SG-1. Read the header of examples/water-mp2v.toml before quoting a number.

Robust fitting. When the RI metric differs from the operator in the integrals, as it does for attenuated operators, robust (Dunlap) density fitting is required, not optional.

RS-MP2 + LR-RPA

What it is. Short-range MP2 plus long-range direct RPA: the attenuated-MP2 idea with the missing long-range correlation supplied by response rather than by a fitted dispersion term. Two formulations:

  • delta-lr (default): \( E_{MP2}[\text{Coulomb}] + (E_{dRPA}[\text{erf}] - 2E_{OS}[\text{erf}]) \), one dRPA call.
  • coupled-rings: \( E_{MP2}[\text{Coulomb}] + \Delta dRPA[\text{Coulomb}] - \Delta dRPA[\text{erfc}] \), two dRPA calls; includes the mixed short/long-range ring diagrams.

Run it. method.kind = "rs-mp2-rpa" (examples/water-rs-mp2-rpa.toml, [mp2] formulation); Python run_rs_mp2_rpa(..., omega=0.420, formulation="delta-lr").

Accuracy. Smoke. The ω→0 and ω→∞ limits reduce exactly to MP2 and to MP2 + dRPA; numbers at production ω are not established on new systems.

Orbital-optimized MP2 and MP3

OO-RI-MP2 (oo-rimp2, examples/water-oo-rimp2.toml, run_oo_rimp2): orbitals optimized for the MP2 Lagrangian, with a level-shifted Newton step, orbital DIIS, Cayley rotations and backtracking. Proven (narrow): the energy matches an independent numpy OO-RI-MP2 to 7.5e-13 Ha (H2O, NH3, UHF CH3 at cc-pVDZ), and the closed-shell analytic gradient matches a finite difference of its own energy to 8e-9 Ha/Bohr. ORCA 6.1.1's OO-RI-MP2 stops 3.7e-8 to 7.0e-8 Ha above the same minimum, so it is only a loose cross-check.

MP3 (mp3, examples/water-mp3.toml, run_mp3): spin-orbital third-order Møller–Plesset through the einsum! framework. Proven.

Laplace formulations

RI-Laplace MP2 (laplace-mp2, examples/water-laplace-rimp2.toml, run_laplace_mp2): MP2 through a Laplace transform of the energy denominator, using pseudo-density matrices in the AO basis. The implementation is dense. It is the correctness reference for that formulation, not a reduced-scaling path, and no reduced scaling has been measured.

Laplace SOS-MP2 (laplace-sos-mp2, examples/water-laplace-sos-mp2.toml, run_laplace_sos_mp2): opposite-spin-only MP2 (Jung et al. 2004, cOS = 1.3 by default) with a minimax Laplace quadrature (n_quad must be 3, 5 or 7; anything else is an error). There is deliberately no cSS: dropping the same-spin term is what lets the denominator factorize. With cOS = 1.0 it reproduces the opposite-spin component of RI-MP2 to quadrature error, which is the test anchor. Three formulations (sos_formulation):

  • mo (default) and ao are both exact and agree to round-off.
  • ao-sparse restricts each Boys-localized orbital's pseudo-density to an AO domain of radius domain_cutoff_bohr (required, no default). It is the one approximate variant.

What is measured for ao-sparse:

  • Against the exact AO path on n-alkanes (cc-pVDZ, cOS = 1, n_quad = 7, 2026-07-28), chemical accuracy (absolute error below 1.6 mHa) needs a domain radius of 3, 3, 3, 4, 5 and 5 Bohr for C2, C4, C6, C8, C10 and C12. The diameter grows fivefold over that series, so radius/diameter falls from 0.52 to 0.17.
  • In the STO-3G tests, a 12 Bohr domain that is exact for butane (10.5 Bohr across) is also exact for octane (19.9 Bohr across), and a 4 Bohr domain on butane is already within 0.1%.
  • A 71-atom drug molecule (danuglipron, 31.3 Bohr across, STO-3G) is within 0.05% at 4 Bohr.

The test sos_ao_sparse_truncation_radius_is_transferable_across_sizes in crates/ferric-mp2/src/laplace.rs pins the STO-3G butane/octane comparison: at 12 Bohr butane is exact (relative error below 1e-9) and octane is within 1e-6, and octane at 3 Bohr is worse than at 12 Bohr. The 4 Bohr butane figure and the danuglipron run (recorded in the test's doc comment) are measurements, not assertions. The alkane sweep is kept in the project's working notes.

How to read it (provisional): the radius needed grows, but far more slowly than the molecule. That points to a finite decay length rather than strict saturation: no single radius is shown to suffice at every size.

What is not claimed: any speedup. The domains discard contributions but the tensor algebra is still dense, so there are no timings to report.

Exact and local MP2

method.kind = "rimp2" is the method; it is computed exactly unless a [local] section asks for a local approximation (see Exact and local correlation). The local approximation is closed shell and energy only.

Amplitude-threshold local MP2 (examples/water-rimp2-local.toml; [local] scheme = "amplitude-threshold", eps = 1e-4; Python run_rimp2(..., local="amplitude-threshold", eps=1e-4)): the single-threshold local MP2 of Wang, Aldossary, Shi, Liu, Li & Head-Gordon (2023), with localized virtuals and per-pair domain-local RI fits. eps has no default: it is part of the model. eps = 0 reproduces RI-MP2 exactly; a finite eps carries a one-sided truncation error that grows faster than linearly in eps. Every printout and run-log record of a local run states eps and the fraction of amplitudes kept.

The exact RI-MP2 reference that measures that error is opt-in. It is a full canonical RI-MP2 and forms the global (naux, nocc·nvir) tensor, so a run with it switched on is not reduced-cost. In the CLI, [local] reference = true computes it and prints the local error against it; without it the output reads E_corr(canonical RI) = not computed (opt-in: set [local] reference = true) and the run log's e_corr_canonical_ri is null. In Python, compute_reference=True computes it; by default result.local["e_corr_canonical_ri"] is None.

Integral-direct local MP2 ([local] integral_direct = true, examples/alkane8-rimp2-local-direct.toml; Python integral_direct=True) is the reduced-cost path. Its correlation assembly never forms the global 3-index tensor; only the opt-in reference does. Locality comes from an integral-free pair gate (gate_cal), per-occupied auxiliary-fit and virtual domains (aux_radius, virt_radius), and truncation of each orbital's AO support (ao_tail). With every map at its trivial setting it reproduces the global 3-index path and canonical RI-MP2 (tests/lmp2_direct.rs).

What is measured for the integral-direct local MP2 (n-alkanes C20 → C48, 6-31G with cc-pVDZ-RI, a quiet machine, 2026-09-07; benchmark bench_direct_alkane_series in crates/ferric-mp2/tests/lmp2_direct.rs). The run froze the carbon cores (frozen_core = number of carbons) and calibrated the pair gate (0.7 Coulomb, 0.02 erfc with ω = 1.0) at eps = 1e-4; the defaults are all-electron with no pair gate. Timings are the correlation stage (assembly + solve) and exclude the canonical reference:

  • Correlation-stage cost grows as about N1.24 with the erfc kernel and N1.4 with Coulomb, fitted to the last three sizes.
  • It is at about parity with canonical RI-MP2 at C20 (about 1.1× slower) and about 6× faster at C32 (5.7–6.3×).

How to read it (provisional): sub-quadratic on this series, but the fit has three points on one family of molecules in one basis. It is not shown to be linear, and it is not measured on 3-D or diffuse systems.

Local MP2 without integral_direct makes no scaling claim. Its assembly is pair-local (no dense J is formed), but it still builds the global 3-index tensor. Use it as the reference implementation and for small systems.

Cite

RI-MP2 auxiliary sets: Weigend et al. 1998. SCS-MP2: Grimme 2003. SOS-MP2: Jung et al. 2004. OO-MP2: Lochan & Head-Gordon 2007; Bozkaya et al. 2011. Laplace MP2: Häser & Almlöf 1992. Attenuated MP2: Goldey & Head-Gordon 2012; terfc: Dutoi & Head-Gordon 2008, Goldey, Dutoi & Head-Gordon 2013; SCS-MP2(2terfc): Goldey & Head-Gordon 2014; MP2-V: Goldey, Belzunces & Head-Gordon 2015. LMP2: Wang et al. 2023. Robust fitting: Dunlap 2000. Full entries in References.

Coupled cluster

"RI-CC" here means the two-electron integrals in the amplitude equations come from density fitting (three-centre B tensors from an RI auxiliary basis), not from exact four-centre integrals. Everything below needs an orbital basis and an RI auxiliary basis. Formal cost is O(N⁶) for CCSD and O(N⁷) for (T).

CCSD and CCSD(T)

What it is. RI-CCSD and the perturbative triples correction (T), in two implementations:

  • Spin-adapted, closed shell (ccsd_closed_shell, ccsd_t_closed_shell): amplitudes over spatial orbitals, the algorithm PySCF's cc.CCSD uses. This is what both the CLI and Python run for an RHF reference. Its VVVV block is 16× smaller than the spin-orbital one; measured about 8–10× faster than the spin-orbital CCSD at cc-pVDZ, and the (T) step 9.6–42× faster.
  • Spin-orbital (ccsd, ccsd_t): built from the same RHF spatial orbitals and kept as the cross-check of the spin-adapted solvers, so it is closed shell only as well. Its (T) streams one occupied triple at a time, so memory is O(no·nv³)-class rather than the dense six-index tensor.

No open-shell CCD, CCSD or CCSD(T) exists in ferric: every CC solver reads restricted orbitals and refuses an unrestricted reference. The only open-shell coupled-cluster-type method is LinLCCD(hh): the CLI linlccd kind is closed shell, and its open-shell (UHF) version is library-only (ferric_cc::linlccd_u::u_linlccd).

Run it.

  • CCSD: method.kind = "ccsd" (examples/water-ccsd.toml, water/cc-pVDZ); Python ferric.run_ccsd(mol, bs, aux).
  • CCSD(T): method.kind = "ccsd(t)" (examples/water-ccsd-t.toml); Python ferric.run_ccsd_t(mol, bs, aux).
  • CCD: method.kind = "ccd" (examples/water-ccd.toml); Python ferric.run_ccd(mol, bs, aux).
  • All three kinds are closed shell only and refuse multiplicity > 1.

Aux basis. The RI error is not negligible at CC accuracy: on water / cc-pVDZ with cc-pvdz-ri, RI-CCSD differs from exact-integral CCSD by about 1.3e-4 Ha. Pick the aux for the accuracy you need, not by habit.

Accuracy. ccsd is Proven.

QuantitySystem / basisReferenceAgreementPinned by
CCSD correlation energyH2 / STO-3Gexact-integral numpy−0.02052453 Haferric-cc/src/ccsd.rs::test_ccsd_h2_sto3g
Closed-shell (T)H2O / cc-pVDZPySCF ccsd_t()~1e-6 Ha (test asserts 1e-4)ferric-cc/src/ccsd_t_closed_shell.rs::closed_shell_t_h2o_ccpvdz_matches_pyscf
Streaming vs dense spin-orbital (T)H2O / cc-pVDZferric's former dense path5e-16 Haferric-cc/src/ccsd_t.rs::streaming_matches_dense_h2o_ccpvdz

Limits. Closed-shell entry points only in the CLI and Python. The spin-adapted (T) rejects spin-orbital amplitudes with a typed error rather than mixing conventions.

LinLCCD and ωB97X-L-V

LinLCCD(hh) is linearized coupled-cluster doubles with the hole–hole ladder kept to all orders, closed shell only. The ladder keeps the correlation energy finite as the HOMO–LUMO gap closes, where MP2 diverges. method.kind = "linlccd" (examples/water-linlccd.toml). Proven (narrow): no other quantum chemistry code implements LinLCCD(hh), so its energy is checked against an independent numpy solve of the same equations on PySCF density-fitted integrals (H2O and NH3 through the closed-shell path, and UHF OH through the library-only open-shell path; agreement ≤1.2e-12 Ha). With the ladder off it reduces exactly to RI-MP2, and with exact integrals its driver terms reproduce canonical MP2.

[mp2] linlccd_variant (Python run_linlccd(variant=...)) selects the method's ladder terms: hh (default), drivers-only (no ladder, equal to RI-MP2) or full (hole–hole plus particle–particle, with CCD-like VVVV memory). Every variant is computed exactly by default. All three variants match an independent numpy solve on the same density-fitted integrals (H2O and NH3, 6-31G and cc-pVDZ, ≤ 4.4e-13 Ha); drivers-only also matches PySCF DFMP2.

Local LinLCCD. With [local] scheme = "amplitude-threshold" and eps (examples/water-linlccd-local.toml; Python run_linlccd(..., local="amplitude-threshold", eps=1e-4)) the same variant is solved in the Boys-localized basis with pair amplitudes at or below eps dropped (see Exact and local correlation). eps has no default and is printed and logged with the kept fraction; eps = 0 reproduces the exact LinLCCD of the same variant and the numpy reference (≤ 4.4e-13 Ha, every variant), which is how the local path is anchored. [local] reference = true also runs the exact LinLCCD and prints the local error. Closed shell and energy only.

ωB97X-L-V is a double-hybrid functional that uses short-range LinLCCD(hh) instead of MP2 for its correlation term. It converges its own ωB97X-L Kohn–Sham reference (a non-converged reference is an error), then adds the LinLCCD(hh) correction on those orbitals. method.kind = "wb97x-l-v" (examples/water-wb97xlv.toml). [dft] lambda and omega override the published 0.6 and 0.1 Bohr⁻¹; omitting them gives the published values. λ enters the amplitude equations as well as the energy (the paper's eqn 22), so the correlation term is quadratic in λ at leading order. Validated against PySCF with the published parameters plus a numpy LinLCCD(hh) on water and OH, and against the paper's Be₂ bond energy (2.3 kcal/mol; ferric 2.299).

MP2-based double hybrids

B2PLYP and DSD-PBEP86: a KS reference with weighted exchange and correlation components, plus scaled (SCS-)RI-MP2 correlation. method.kind = "b2plyp" / "dsd-pbep86" (examples/water-b2plyp.toml); Python ferric.run_double_hybrid(mol, bs, aux, kind="b2plyp"). Spike: no comparison to a reference code yet.

Implementation

All contractions go through einsum!, a macro that maps tensor contractions onto BLAS3 GEMMs. The permutation copies that feed those GEMMs are parallelized, because for a strided permutation the copy can dominate the contraction it feeds: measured at 47% at nv = 40 and 70% at nv = 80. The copies are bit-identical regardless of thread count, since a permutation writes each output element exactly once; a test pins this and has been checked to fail when deliberately broken.

Memory

The amplitude tensors dominate and grow as \( n_o^2 n_v^2 \), or \( (2n_o)^2 (2n_v)^2 \) in the spin-orbital drivers. Memory budgets are enforced: an oversized job is refused with a breakdown naming the dominant term instead of being OOM-killed partway through.

Cite

CCSD: Scuseria, Janssen & Schaefer 1988; spin-adapted closed-shell CCSD equations: Hirata et al. 2004. (T): Raghavachari et al. 1989; closed-shell (T) algorithm: Rendell, Lee & Komornicki 1991. LinLCCD(hh): Carter-Fenk 2025. ωB97X-L-V: Ransford & Carter-Fenk 2026. B2PLYP: Grimme 2006. DSD-PBEP86: Kozuch & Martin 2011. Review: Bartlett & Musiał 2007. Full entries in References.

RPA, GW and excited states

Methods built on the density–density response function: RPA correlation energies, GW quasiparticle energies, BSE and TDDFT excitations, and polarizabilities and \( C_6 \) coefficients. All need an RI auxiliary basis ([rpa] auxbasis). The [rpa], [gw] and [tddft] keys are in Input file; grades are on Capabilities and validation.

What PDEP does in ferric

The independent-particle response \( \chi_0(i\omega) \) is built in the RI auxiliary basis from the three-centre B tensors, as an explicit (Adler–Wiser) sum over every occupied–virtual pair \( ia \), weighted by \( 4\varepsilon_{ia}/(\omega^2 + \varepsilon_{ia}^2) \) (sternheimer::dielectric_matrix). The sum over empty states is still there.

PDEP (projective dielectric eigenpotentials) then works in the eigenbasis of the static dielectric matrix in that RI space. Eigenpotentials whose eigenvalue is within trunc_thresh of 1 (default 1e-4) carry almost no screening and are dropped, so the frequency-dependent work runs in a smaller basis. That compression, not the removal of the empty-state sum, is what PDEP contributes here. How much it saves depends on the threshold; runs that need the full-rank answer set trunc_thresh = 0.0, as the GW, BSE and \( C_6 \) examples do.

RPA correlation energy

What it is. Direct RPA (dRPA) correlation from the dielectric eigenvalues on an imaginary-frequency quadrature.

  • PDEP-RPA, closed shell: method.kind = "pdep-rpa" (examples/water-pdep-rpa.toml); Python ferric.run_pdep_rpa. Proven.
  • U-PDEP-RPA, open shell over a spin-summed dielectric. From the CLI, set method.kind = "pdep-rpa" with multiplicity > 1 and task = "energy": the CLI solves UHF (UKS with [rpa] xc) with MOM after 5 iterations and runs U-PDEP-RPA on it. It is CLI-only: Python run_pdep_rpa is closed shell only. The library (ferric_rpa::run_u_pdep_rpa) also accepts a ROHF (or ROKS) reference, which it semi-canonicalizes first: each spin uses the orbitals and energies of its own Fock matrix, diagonalized in its occupied and virtual blocks (see the anchors).
  • Attenuated RPA: short-range correlation with an erfc operator.
  • RS-MP2 + LR-RPA: short-range MP2 plus long-range dRPA, on the MP2 page.

The static eigensolve defaults to Lanczos, with a dense path for small problems. Geometry optimization with pdep-rpa is supported (task = "optimize") on a closed-shell RHF reference.

Exact and local dRPA

What it is. method.kind = "drpa" (Python ferric.run_drpa) is dRPA@HF computed by the drCCD Riccati equations in the Boys-localized basis, closed shell, energy only. It is exact by default: no amplitude is truncated (examples/water-drpa.toml), and the energy equals the canonical plasmon formula to ≤ 1e-12 Ha. Riccati, plasmon and full-rank PDEP-RPA are algorithms for the same exact dRPA; measured agreement is ≤ 2.6e-14 Ha (H2/STO-3G, water/6-31G with and without frozen core, PDEP with trunc_thresh = 0 at 64 Gauss–Legendre points). Proven (narrow).

Which exact algorithm. The Riccati solve holds a ring-product plan of no³·nv² numbers, no times the size of the amplitudes, so it is the small-system path: C12 thrashed and was then killed for memory. A run that cannot fit the memory budget is refused before the SCF and pointed at pdep-rpa with [rpa] trunc_thresh = 0, which gives the same energy (to its frequency-quadrature error) at far lower memory and is faster at every size measured (n-alkanes C4–C16).

The local approximation ([local] scheme = "amplitude-threshold" with eps, examples/water-drpa-local.toml; Python run_drpa(..., local="amplitude-threshold", eps=1e-4)) drops pair amplitudes whose localized |2(ia|jb)| is at or below eps. eps has no default and is printed and logged with the kept fraction; eps = 0 is the exact method. dRPA is not variational, so the error is first order in what is dropped. It is positive (less correlation) at every point measured, which is a measurement, not a guarantee, and grows faster than linearly in eps: on n-octane / 6-31G it is 6.7e-7, 3.9e-5, 5.5e-4 and 1.0e-2 Ha at eps = 1e-6, 1e-5, 1e-4 and 1e-3, keeping 82%, 50%, 18% and 3% of the amplitudes. [local] reference = true (Python compute_reference=True) also computes the canonical plasmon dRPA and prints the error against it. [local] eps_sweep (Python run_drpa_scan) evaluates several eps on one SCF and one localized assembly. Proven (narrow) through its exact limit.

What is measured (n-alkanes, 6-31G / cc-pVDZ-RI, frozen carbon cores, Coulomb, single thread; local error against the plasmon formula, PDEP error against full-rank PDEP at 32 quadrature points). The fraction of the truncated object retained at 1 kcal/mol error:

SizeLocal dRPA (amplitudes kept)PDEP (modes kept)
C423.3%17.9%
C810.0%21.5%
C124.9%22.6%
C162.8%23.3%

The amplitude threshold compresses more as the molecule grows, and PDEP's fraction stays flat. The two fractions are of different objects (no²nv² amplitudes against naux modes), so this is not a cost comparison. In wall time at a matched error of about 1 kcal/mol (reference off), truncated PDEP stays about 8× faster from C4 to C16 (C16: 165.7 s local at eps = 1e-4 against about 21 s PDEP), and both grow at the same rate in that range. No speedup over PDEP is claimed for local dRPA.

GW

What it is. Quasiparticle energies from the GW self-energy: G0W0, COHSEX, evGW0 and evGW, closed shell, plus unrestricted U-GW. The starting point is HF by default or a KS functional ([rpa] xc). From a KS starting point the static term Σx − v_xc enters the G0W0, evGW₀ and evGW quasiparticle equation (for U-GW, each spin's own equation with that spin's v_xc), so Σc is evaluated at the shifted root; COHSEX and U-COHSEX, which are static, add it to the quasiparticle energy.

Run it. method.kind = "gw" with [gw] method = "g0w0" (examples/water-g0w0-pbe.toml, open shell examples/oh-ugw.toml); Python ferric.run_gw, run_u_gw. The open-shell reference is UHF by default; [gw] reference = "rohf" (Python run_u_gw(reference="rohf")) uses ROHF instead, or ROKS with [rpa] xc (examples/oh-ugw-rohf.toml), semi-canonicalized per spin as for U-PDEP-RPA.

Accuracy. Smoke; treat results as about ±0.3 eV.

QuantitySystem / basisReferencePinned by
G0W0@HF, G0W0@PBE, U-G0W0@UHF, ECP, COHSEX, evGW₀, evGW quasiparticle energies (HOMO−2 to LUMO+2)H2O, NH3, N2, OH, CH3, NH2, O2, CH2, I2, Xe, Ag2 / cc-pVDZ, aug-cc-pVDZ(-PP)PySCF gw_ac/ugw_ac at matched settings ([rpa] n_quad = 100, trunc_thresh = 0); see What is validatedferric-gw/tests/validation_gw.rs
G0W0@PBE HOMO IPH2O / cc-pVDZPySCF gw_ac, 11.1714 eV; asserted to <0.1 eVferric-gw/tests/g0w0_pbe_h2o.rs
U-G0W0@UKS/PBE quasiparticle energies, Σx − v_xc inside each spin's equationOH, CH3, NH2 / cc-pVDZPySCF ugw_ac Σc(ef + iω), quasiparticle equation solved in numpy: ≤2.6e-8 Ha on orbitals whose quasiparticle equation has a single rootferric-gw/tests/validation_gw.rs
Σx − v_xc placement in U-GW (inside the quasiparticle equation; none for a UHF reference)OH / STO-3Ginternal: shifted residual, bit-identity of the no-shift pathferric-gw/tests/u_gw_ks_shift.rs
U-G0W0@UHF α-HOMO IPOH / cc-pVDZ~13–14 eV window, brackets experiment 13.02 eVferric-gw/tests/oh_u_g0w0.rs

Limits. The quasiparticle equation is solved by a Newton root search on the self-energy, which is fragile near \( \Sigma_c \) poles. Runs report whether each root and each eigenvalue-self-consistency loop converged, and warn when one did not; check those flags.

BSE-TDA

What it is. Bethe–Salpeter excitation energies in the Tamm–Dancoff approximation on top of G0W0@HF quasiparticle energies, closed shell.

Run it. method.kind = "bse-tda" (examples/water-bse-tda.toml, and a set of *-bse-tda-augdz.toml examples for small organics); Python ferric.run_bse_tda.

Accuracy. Smoke. Given the same quasiparticle energies, the lowest five singlet excitation energies match an independent numpy BSE-TDA (PySCF density-fitted integrals, static RPA W) to 1.8e-10 Ha and their oscillator strengths to 2.6e-9, for H2O at cc-pVDZ and aug-cc-pVDZ and NH3 and CH2O at cc-pVDZ. The quasiparticle energies come from the internal G0W0@HF, which matches PySCF only at [rpa] n_quad = 100 and trunc_thresh = 0; the defaults are coarser. The core and high-virtual quasiparticle energies are ill-conditioned, which moves the lowest five excitations by at most 2.2e-7 Ha.

TDDFT and TDA

What it is. Linear-response excitations in the Tamm–Dancoff approximation (TDA, which is CIS for an HF reference) and the full Casida equations, closed shell.

Run it. method.kind = "tda" or "tddft" with [tddft] n_roots and optionally xc (examples/water-tda.toml, examples/water-tddft-pbe.toml); Python ferric.run_tddft(mol, bs, aux, functional=..., method="tda") or method="casida".

Scope. Closed-shell references, singlet excitations. With a DFT reference the \( (ia|f_{xc}|jb) \) XC-kernel term is included; with no functional the result is CIS/TDHF. Meta-GGA, VV10 and range-separated functionals are refused. Grade: Proven (narrow, closed shell): water, formaldehyde and NH3 at 6-31G and aug-cc-pVDZ with HF, LDA, PBE and B3LYP match PySCF TDA/TDDFT to at most 6.5e-4 eV, test bar 1e-3 eV (ferric-tddft/tests/validation_tddft.rs).

A separate, library-only TDA-DFT in ferric-gw/src/tddft.rs uses the same kernel (ferric_dft::lr_kernel) and is pinned against PySCF (ferric-gw/tests/tda_dft_vs_pyscf.rs). The user-facing TDA reproduces it to 1e-8 Ha for HF, PBE and B3LYP.

Polarizabilities and dispersion coefficients

What exists. Static molecular and atom-partitioned polarizabilities, Casimir–Polder \( C_6 \) coefficients from per-atom dynamic polarizabilities \( \alpha^A(i\omega) \), and many-body dispersion (MBD). Three sources feed the \( C_6 \) contraction ([rpa] c6_source):

  • ts: the Tkatchenko–Scheffler single-pole model (default).
  • mbd: self-consistent dipole screening of the TS polarizabilities. This is the full-range screening of Tkatchenko et al. 2012 (Gaussian-damped dipole tensor, no Fermi range separation), not the range-separated screening of MBD@rsSCS.
  • pdep: dynamic PDEP-RPA polarizabilities (examples/water-c6-pdep.toml, examples/argon-c6-rpa-pbe.toml).

MBD@rsSCS dispersion energy. ferric.mbd_rsscs_energy(mol, volume_ratios, functional=... | beta=...) (Rust: ferric_rpa::dispersion::mbd_rsscs_energy) computes the MBD@rsSCS energy of Ambrosetti et al. 2014 for a finite molecule, standalone (no SCF). Inputs are per-atom Hirshfeld volume ratios, which scale the free-atom α, C6 and R_vdW (Z = 1–54). The polarizabilities are screened with the short-range part (1 − f) of the Gaussian-damped dipole tensor on libMBD's 15-point imaginary-frequency grid; the energy couples the screened oscillators through the long-range part f of the bare dipole tensor, with the Fermi function f(R) = 1/(1 + exp(−6(R/(β(R_A + R_B)) − 1))). β is functional dependent: PBE 0.83, PBE0 and HSE06 0.85; any other functional needs an explicit beta. It returns the energy and the screened α₀, C6, R_vdW and ω per atom, and raises on a polarization catastrophe (a non-positive coupled-oscillator eigenvalue). No periodic systems. As a dispersion correction on a Kohn–Sham SCF it is [dft] dispersion = "mbd" / run_dft(dispersion="mbd"), which take the volume ratios from the converged density (reported as DftResult.volume_ratios) and add the energy and its analytic gradient (see SCF and DFT).

The argon-c6-rpa-pbe.toml header records C6(Ar–Ar) = 56.4 a.u. at RPA@PBE/aug-cc-pVTZ against the DOSD value 64.3 (−12%). Which of TS and PDEP-RPA gives better molecular \( C_6 \) is not established.

Use an augmented basis for any polarizability or \( C_6 \): without diffuse functions the dipole response is badly underestimated.

TDHF/RPAx \( C_6 \) is a measured negative. \( C_6 \) built on the RPAx@PBE kernel stays about 63% low regardless of the gap. Its static polarizability is not established either: at a physical scissor (0.36 Ha) water/cc-pVDZ gives an isotropic α of 5.20 a.u. against the DOSD 9.64 a.u. (−46%). The 9.24 a.u. quoted in the example's header comes from scissor = 0, where the tensor has a negative diagonal component, and that setting is refused. method.kind = "tdhf-static-polarizability" computes static α only. It needs a KS reference ([rpa] xc), and at the default [gw] scissor = 0 it can hit an excitonic instability, which is reported as an error rather than a negative α; examples/water-tdhf-static-alpha.toml hits it as shipped, so set scissor to about 0.3–0.4 Ha.

Cite

PDEP: Wilson, Gygi & Galli 2008. RI-RPA quadrature: Eshuis, Yarkony & Furche 2010; minimax grids: Kaltak, Klimeš & Kresse 2014. GW: Hedin 1965; GW100: van Setten et al. 2015. TDDFT review: Dreuw & Head-Gordon 2005. TS: Tkatchenko & Scheffler 2009; MBD: Tkatchenko et al. 2012; MBD@rsSCS: Ambrosetti et al. 2014. Full entries in References.

Constrained DFT

Charge- and spin-constrained DFT, and the electron-transfer couplings that follow from it.

Run it

Python and the Rust library; there is no CLI section.

  • run_cdft(mol, basis_set, constraints, functional=None, ...) returns a CdftResult: a constrained UHF solve, or UKS when functional names a libxc functional other than "HF" (None and "HF", any case, give UHF).
  • CdftConstraint(atoms, target, kind="charge") defines one fragment constraint. atoms are 0-based atom indices. target is the electron population on the fragment, the Becke-weighted trace \( \mathrm{Tr}[W D] \), not a net charge: \( N_\alpha + N_\beta \) for kind="charge" and \( N_\alpha - N_\beta \) for kind="spin". A neutral He atom has a charge population of 2.0; He⁺ has 1.0.
  • cdft_coupling(state_a, state_b) returns a CdftCouplingResult with the Wu–Van Voorhis coupling h_ab, the determinant overlap s_ab and the two diabat energies e_a, e_b. The raw element uses each state's free energy F = E + λN, which makes the coupling independent of a constant shift of the constraint operator.

In Rust the entry points are ferric_scf::cdft_driver::solve_cdft_uhf and ferric_scf::cdft_coupling::coupling_hab; see the Rust API.

This example reproduces the HeNe⁺ constrained solution that crates/ferric-scf/tests/cdft_outer_loop.rs pins (E = −130.4021906 Ha, λ = −2.754 Ha per electron), with the He fragment held at 2.0 electrons:

import ferric

# HeNe+ doublet at 2.0 Å; hold the He atom (index 0) at 2.0 electrons.
mol = ferric.Molecule.from_xyz_string(
    "2\nHeNe+\nHe 0.0 0.0 0.0\nNe 0.0 0.0 2.0\n", 1, 2
)
r = ferric.run_cdft(
    mol,
    ferric.BasisSet.bundled("def2-svp"),
    [ferric.CdftConstraint([0], 2.0, kind="charge")],
    guess="hcore",
    level_shift=0.5,
    max_iter=400,
    lambda_tol=1e-5,
    max_outer=40,
    stability_descent=False,
    grid_radial=99,
    grid_angular=302,
)
print(r.converged)
print(f"{r.energy:.6f}")
print(f"{r.populations[0]:.5f}")
True
-130.402190
2.00000

r.energy is the ordinary UHF/UKS energy at the constrained density, without the constraint term. r.lambdas holds the multipliers (Ha per electron) and r.weight_matrix(i) the AO weight operator of constraint i.

What to know before using it:

  • A returned result has a converged λ loop. When the outer loop exceeds max_outer, run_cdft raises RuntimeError. The inner SCF at the final λ can still be unconverged, so check r.converged: it is true only when the inner SCF converged and every constraint is met to lambda_tol.
  • stability_descent defaults to True here (in run_uhf it defaults to False). After the λ loop converges, a constrained saddle point is followed downhill to a lower state that still meets the constraint. The example turns it off to reproduce the pinned solution; with it on, this HeNe⁺ case ends about 0.0245 Ha lower. The descent is skipped for a KS reference.
  • The weight grid defaults to 99 × 302. It must resolve populations below lambda_tol; 75 × 110 resolves them only to about 1e-4. Setting grid_radial or grid_angular uses that grid for the XC quadrature too.
  • The UKS path is the one compared against another code. Constrained UKS/PBE matches NWChem 7.2.2 (see Accuracy). The UHF path (functional=None or "HF") has only internal checks: NWChem's standalone SCF (Hartree–Fock) module has no cDFT, because the Becke weight operator is built on the XC grid of its DFT module. A Hartree–Fock comparison through that DFT module (xc HFexch) has not been made.
  • One constraint is the tested case. For one constraint the outer loop is a Newton step kept inside a sign-change bracket. It backs off from a λ whose inner SCF does not converge, and it discards a finite-difference derivative whose two inner solves landed in different SCF states. With several constraints the outer loop is a plain k × k Newton step without these safeguards.
  • cdft_coupling has strict preconditions and raises ValueError when one fails. Each state carries exactly one kind="charge" constraint, both are converged, and both come from the same molecule, geometry, basis, charge and multiplicity and the same Hamiltonian: functional, df_j_aux/df_k_aux, k_builder, XC grid, point charges and external field. Two states that are the same determinant (\( |S_{ab}| \to 1 \)) also raise. The sign of h_ab is a determinant-phase convention; compare \( |H_{ab}| \).

Accuracy

Constrained UKS/PBE energies and multipliers are compared against NWChem 7.2.2 cdft ... pop becke on LiH, HF and H2O⁺ (charge and spin constraints) at 6-31G and def2-SVP (ferric-scf/tests/validation_cdft.rs): E(N) − E_unconstrained to 2.0e-7 Ha and λ to 1.1e-6 against NWChem's grid limit, and dE/dN = −λ to 2.3e-12 Ha (this identity is checked at def2-SVP, on the first target of each constraint kind); numbers are on Capabilities and validation. No external reference value is stated for UHF-cDFT energies. The He₂⁺ coupling ingredients (determinant overlap, one- and two-electron transition elements, |V|) match NWChem's et module to ≤ 6e-11 Ha on NWChem's own determinants, and the KS diabats and couplings match end to end when started from NWChem's λ, except def2-SVP at 3.50 Å, where inner SCF solves within 1e-9 in λ of the root do not converge in 100 iterations (see Capabilities and validation). These diabats are validated only from NWChem's λ, set through the Rust RhfConfig::cdft_lambda_init; run_cdft starts from λ = 0, where the symmetric pair is delocalized and the inner SCF is bistable. The tests also check the coupling kernel on synthetic matrices and He₂⁺ identities (ferric-scf/tests/cdft_coupling.rs), probe HeNe⁺ over a distance series (cdft_coupling_hene.rs), and check exact identities on LiH/def2-SVP (cdft_uhf.rs): the constraint is satisfied, λ = 0 reproduces plain UHF, and the constraint composes with an external point charge. The Python tests (crates/ferric-python/tests/test_cdft.py) rerun those configurations through the bindings and check cdft_coupling against an independent transition-density construction. Treat UHF-cDFT energies as unvalidated against other codes; see Capabilities and validation.

The response connection

A cDFT constraint couples a Lagrange multiplier \( \lambda \) to a fragment-weighted density operator. The derivative

\[ \frac{\partial N}{\partial \lambda} \]

— how much charge moves per unit constraint potential — is a susceptibility. So cDFT probes the same object as RPA and GW and attenuated MP2, through a different coupling.

Implementation

  • Fragment charge and spin constraints via a grid-Becke weight operator
  • A nested Lagrange-multiplier solve (Wu–Van Voorhis): an inner SCF at fixed \( \lambda \), an outer Newton iteration on \( \lambda \) itself

The nesting is what makes cDFT more expensive than a plain SCF — each outer step is a full converged inner solve.

Electron-transfer coupling

Once you have two charge-localized diabatic states, the coupling \( H_{ab} \) between them follows from a non-orthogonal determinant overlap, computed via Löwdin biorthogonalization.

That gives the matrix element governing electron-transfer rates in Marcus theory, from states that are constructed rather than guessed.

A caveat

The λ convergence tolerance (lambda_tol in Python, cdft_lambda_tol in Rust, default 1e-5 electrons) interacts with the coupling calculation in a way worth checking: a loosely converged \( \lambda \) produces diabatic states that are not quite the ones you asked for, and \( H_{ab} \) inherits that error. Tighten it before trusting a coupling. The exception is a constraint whose population barely responds to \( \lambda \), such as He₂⁺ at the localized-hole plateau: there the tolerance has to be loosened to match that flatness (the tests use 1e-2), or the Newton step drives \( \lambda \) off a cliff.

Cite

Wu & Van Voorhis 2006 (electron-transfer coupling from cDFT); Becke 1988 (the fragment weight partition). Full entries in References.

Electronic response

ferric is organized around electronic response: how the electron density reacts to a perturbation. Standard quantum-chemistry codes are usually organized around a hierarchy of wavefunction ansätze (HF → MP2 → CCSD → CCSD(T)). That is a perfectly good organizing principle. It is not the one used here.

The central object appears under several names depending on which coupling you look at:

ObjectDefinitionWhere it appears in ferric
Density response \( \chi \)\( \chi = \delta\rho / \delta v_{\text{ext}} \); \( \chi_0 \) is its independent-particle formRPA, GW, MP2's dispersion
Dielectric matrix \( \varepsilon \)\( \varepsilon = 1 - v\chi_0 \) (RPA), with \( v \) the Coulomb kernelPDEP-RPA and GW screening
Polarizability \( \alpha \)response of the dipole to a uniform field; \( \alpha(i\omega) \) gives \( C_6 \)polarizabilities, dispersion coefficients
\( \partial N / \partial \lambda \)charge moved per unit constraint potentialconstrained DFT

These are the same physics viewed through different couplings. A code that computes one well should be able to compute the others, and errors in one should be diagnosable as errors in the others.

The claim

The premise behind the architecture is that response is:

  1. Local in real space: a density fluctuation here does not much affect the density far away, so the response should be sparse in a localized basis.
  2. Low-rank in its eigenspectrum: the dielectric matrix has a small number of dominant eigenmodes, so it can be compressed without losing the physics.

If both hold, organizing the computation around response should make it cheaper: attenuate the operator, keep the dominant dielectric modes.

What is actually demonstrated

Low rank: demonstrated, and narrower than it sounds. PDEP compresses the dielectric matrix to its dominant eigenpotentials in the RI auxiliary space, and that works in production paths; see RPA and GW. It does not remove the sum over empty states: ferric builds \( \chi_0 \) by summing over every occupied–virtual pair, and PDEP compresses what comes out of that sum.

Locality: one positive result, still without a speedup.

  • AO-sparse Laplace SOS-MP2. Restricting each localized orbital's pseudo-density to an AO domain works: the radius needed grows far more slowly than the molecule (chemical accuracy at 3 to 5 Bohr from ethane to dodecane, while radius/diameter falls from 0.52 to 0.17; within 0.05% at 4 Bohr on a 71-atom drug molecule). The tensor algebra is still dense, so no timing gain is claimed. This result is specific to that formulation and does not carry over to the other locality lanes below. The test sos_ao_sparse_truncation_radius_is_transferable_across_sizes pins the STO-3G butane/octane comparison (12 Bohr exact on both; octane worse at 3 Bohr). The C2-C12 sweep and the drug-molecule figure are measurements, not regression tests.
  • Local MP2 (amplitude threshold) has localized virtuals and per-pair domain-local RI fits. The integral-direct variant (rimp2 with [local] integral_direct = true) is measured at about N1.24 (erfc) to N1.4 (Coulomb) on alkanes C20–C48, three points in one basis, so the reading is provisional. Local MP2 without integral_direct still builds the global 3-index tensor and makes no scaling claim.
  • RI-Laplace MP2 is dense; it is the correctness reference for the AO formulation, not a reduced-scaling path.

The locality premise is therefore partly supported and not yet cashed in as cost. Measured limits are kept on Capabilities and validation.

Why this framing is useful anyway

Even where the scaling payoff has not arrived, the response framing makes the error in one method diagnosable through another.

MP2's dispersion is the clearest case. Its dispersion energy is built from an uncoupled (uncoupled Hartree–Fock) response, in which the density fluctuation does not feel the field it creates. For systems with low-lying, highly polarizable excitations, such as π-stacked and other π systems, that uncoupled response over-polarizes, and MP2 overbinds. This is documented in the literature that replaces MP2's uncoupled dispersion with a coupled one (Cybulski & Lytle 2007; Heßelmann 2008; Pitoňák & Heßelmann 2010). The size of that coupling correction varies from system to system, and ferric has no coupled-dispersion (MP2C-style) implementation of its own, so read this as the literature's diagnosis, not a ferric measurement.

Attenuated MP2 takes a blunter route: it removes the long-range correlation altogether and relies on a fitted short-range operator plus the basis-set error it is fitted in. That is why it has zero asymptotic \( C_6 \), why its parameters belong to one basis, and why MP2-V adds long-range dispersion back through VV10. RS-MP2 + LR-RPA puts the long-range part back through response instead. See The MP2 family.

Where the methods come from

The method families in ferric are not an arbitrary selection. Each one works on the response function from a different direction. The definitions and parameters live on the method pages; this page is the map.

Attenuated MP2: removing the long-range part

MP2's dispersion comes from an uncoupled response, which over-polarizes for systems with low-lying, highly polarizable excitations (π-stacked aromatics are the classic case); it is not a general property of polarizable molecules. In small basis sets the resulting overbinding is partly cancelled by basis-set superposition error, which disguises it.

Attenuated MP2 replaces \( 1/r \) in the correlation energy with a short-range operator (erfc or terfc) and fits its range to interaction energies in a chosen basis. It removes the long-range correlation entirely rather than correcting it, so it has no asymptotic \( C_6 \), and its parameters are specific to the basis and protocol they were fitted in. MP2-V restores long-range dispersion with VV10; RS-MP2 + LR-RPA restores it with long-range RPA.

Goldey & Head-Gordon (JPCL 2012) introduced it in aug-cc-pVDZ; Goldey, Dutoi & Head-Gordon (PCCP 2013) introduced terfc in aug-cc-pVTZ; the dual-attenuated SCS variant is Goldey & Head-Gordon (JPCB 2014). Operators, parameters and fitting protocol: The MP2 family.

PDEP-RPA and GW: compressing the response

The dielectric matrix is built from the density–density response function. ferric forms the independent-particle response in the RI auxiliary basis by summing over occupied–virtual pairs, then works in the eigenbasis of the static dielectric matrix, dropping eigenpotentials that carry almost no screening. PDEP (projective dielectric eigenpotentials) is that compression. It is the demonstrated part of the low-rank premise; the empty-state sum that feeds it is still there. See RPA, GW and excited states.

Constrained DFT: reading the response

A cDFT constraint couples a Lagrange multiplier \( \lambda \) to a fragment-weighted density operator. The derivative \( \partial N / \partial \lambda \), how much charge moves per unit constraint potential, is a susceptibility.

That makes cDFT a direct probe of the same object. It also yields charge-localized diabatic states whose electron-transfer couplings \( H_{ab} \) follow from non-orthogonal determinant overlaps. See Constrained DFT.

What this buys

Three families, one object. An error in the polarizability shows up as an error in dispersion, in screening, and in charge-transfer coupling, so a fix validated in one place has predictable consequences in the others.

That is the design bet. Whether it pays off in cost is still open; see Electronic response for what has and has not been measured.

Architecture

A Cargo workspace of focused crates, layered so that method crates depend on integrals and core, not on each other.

                          +------------------+
                          |   ferric-cli     |   TOML config -> most methods
                          +--------+---------+   (+ ferric-python: pyo3 bindings)
                                   |
   +-----------+-----------+-------+------+-----------+------------+
   |           |           |              |           |            |
+--v----+ +----v----+ +----v----+   +-----v----+ +----v-----+ +---v------+
|ferric | |ferric   | |ferric   |   |ferric    | |ferric    | |ferric    |
|-scf   | |-mp2     | |-dft     |   |-rpa      | |-gw       | |-cc       |
|RHF/UHF| |RI-MP2,  | |RKS/UKS/ |   |PDEP-RPA, | |G0W0,     | |CCD/CCSD/ |
|/ROHF, | |OO,att,  | |ROKS,    |   |U-PDEP,   | |COHSEX,   | |(T)       |
|KS-DFT,| |SCS,     | |libxc,   |   |response  | |evGW,     | +----------+
|DIIS,  | |2terfc,  | |Becke    |   |props,    | |U-GW      |
|MOM,AH,| |Laplace  | |grids,   |   |ESP/Hirsh/| +-----+----+
|cDFT,  | +----+----+ |VV10     |   |NPZ export|       |
|grads  |      |      +----+----+   +-----+----+       |
+---+---+      |           |              |            |
    |          +-----+-----+------+-------+------------+
    |                |     |      |
    |   +------------v--+ +v------v-----+   ferric-tensors (sparse),
    |   |ferric-export | |ferric-      |   ferric-quadrature (Laplace/grid roots)
    |   |cube,NPZ,GTO  | |integrals    |   support crates
    |   +--------------+ |libint2 FFI  |
    |                    |shim/shim.cc |   Coulomb/erf/erfc, 1e/2e/3c/2c, derivs
    +--------+-----------+------+------+
             |                  |
        +----v------------------v----+
        |        ferric-core         |   Molecule, BasisSet, Shell, elements,
        |                            |   BSE-JSON / G94 parsers, bundled bases
        +----------------------------+

Layers

ferric-core — molecular structure, basis sets, shells, elements, and the BSE-JSON / Gaussian-94 parsers. Also the home of shared infrastructure: configuration (ConfigVar), memory budgets (MemoryPlan), the BLAS-thread hazard model, and MPI context.

ferric-integrals — the libint2 FFI and its C++ shim. Coulomb, erf and erfc operators; 1-electron, 2-electron, 3-center and 2-center integrals; first derivatives. The shim wraps every libint2 call in try/catch and returns a sentinel, so a C++ exception never unwinds across the FFI boundary.

Method crates — ferric-scf, ferric-mp2, ferric-dft, ferric-rpa, ferric-gw, ferric-cc, ferric-ci, ferric-tddft, ferric-pcm, ferric-mm, ferric-xtb, ferric-d3 (Grimme D3(BJ) dispersion energy and gradient).

Support — ferric-tensors (the einsum! contraction macro), ferric-quadrature (Lebedev grids, minimax Laplace roots), ferric-export (cube files, NPZ, GTO evaluation).

Interfaces — ferric-cli (TOML-driven) and ferric-python (pyo3).

Cross-cutting conventions

Threading. rayon owns outer parallelism; BLAS is pinned to one thread inside any rayon worker, enforced at runtime rather than by convention. Throughput across many jobs comes from many single-threaded processes, not one wide job.

Determinism. Reductions fold in a fixed ascending order independent of thread count, so results are bit-identical across RAYON_NUM_THREADS. This is pinned by tests. A different-but-deterministic order — a tree-fold, say — would not be acceptable, because floating-point addition is not associative.

Memory. MemoryPlan expresses what a path will allocate and when. An allocation that exceeds the budget and has no spill-to-disk or recompute fallback is refused before allocating, with a breakdown naming the dominant term. The CLI installs one process-wide MemoryPool sized from the resolved budget, and the large, size-dependent allocations reserve their bytes from it, so two allocations alive at the same time cannot each claim the whole budget. Library and Python callers install no pool; each check then compares its own allocation with the whole budget. Allocations that have a fallback spill to disk or are recomputed instead of being refused, and basis-sized matrices, engines and scratch are not charged (see Sharp bits). Guards are tested in both directions: a starved budget must be refused (or must fall back), and an ample budget must still run.

Errors. Methods return Result; iterative solvers additionally carry a converged flag, since non-convergence is a result rather than an error. Callers are expected to check it.

Capabilities and validation

Implemented ≠ validated. Working code is not a checked number.

Read this page before trusting any result. It lists every CLI method.kind (which references it accepts, which tasks it supports, the matching Python function, an example input) and the Python entry points. Each graded CLI method.kind gets a grade (three kinds are ungraded, and Python entry points are not graded individually), and the page shows how far each grade's numbers have been checked against an independent reference and where they are known to fail.

How to read it: find the capability in the matrix or the Python entry points. A matrix Grade cell links to the Anchors table when a row there holds the evidence; the Known limits section records the measured negatives. Every matrix cell comes from the CLI dispatch in crates/ferric-cli/src/lib.rs or the bindings in crates/ferric-python/src/lib.rs. The page is maintained by hand, so check those two files if a cell looks wrong. For keyword details see the input reference; for the example files see the examples index.

Grades

The grades are the ones ferric's CLI uses. Every CLI method.kind except laplace-sos-mp2 has a grade. A kind's grade is for the EXACT method; the local approximation of rimp2, drpa and linlccd (the [local] section) is graded separately in that row's Caveat. The CLI prints the Smoke and Spike grades as a [warning] line on stderr at run time; Proven kinds and the ungraded kind print nothing. The warning text is the EPISTEMIC_WARNINGS table in crates/ferric-cli/src/lib.rs, and the "Caveat" column of the matrix condenses it.

GradeMeaning
ProvenTotal energies (or the stated property) agree at a stated tolerance with an independent reference, pinned by tests. An independent reference is another code (PySCF, MOLGW), a numpy reference, a published value, or an exact limit the method must reduce to. The CLI groups "Proven" and "Proven (narrow)" together and does not say which kinds are narrow.
Proven (narrow)As Proven, but only on the stated class of systems, or only against the stated kind of reference (for example exact limits only)
SmokeRuns end to end, and some pieces are checked, but there is no independent reference for the headline number, or only one loose one. The checks are range bands, internal consistency or limits rather than a tight external number. Do not quote a Smoke result as a reference value.
SpikeBuilt on new infrastructure and not yet compared against any reference code; for exploration only
not gradedDispatched by the CLI but in neither its Proven list nor its warning table, so it prints no warning; treat it as unproven

Symbols: ✓ = supported. — = not supported (the CLI refuses it with an error). FD = finite differences of the analytic gradient (6N gradient evaluations). The analytic Hessian covers closed-shell RHF and UHF of any multiplicity with exact J/K, no ECP and a basis up to f functions; rhf and uhf frequencies use it there by default.

CLI method.kind matrix

"Reference" is the SCF reference the CLI actually builds for that kind. Open-shell support is listed only where the dispatch code handles it (see Open shells below).

method.kindFamilyReference (CLI)Energytask = "optimize"task = "frequencies"PythonExampleGradeCaveat
rhfSCFRHF; RKS with [dft] functional (refuses multiplicity > 1)✓✓ analyticanalytic (RHF, exact J/K, no ECP, up to f); FD otherwiserun_rhf, run_optimize, run_frequencieswater-rhf.tomlProven—
uhfSCFUHF; UKS with [dft] functional✓✓ analytic (+ D3(BJ) or MBD@rsSCS gradient with [dft] dispersion on UKS)analytic (UHF, exact J/K, no ECP, up to f); FD otherwiserun_uhf, run_frequencies(reference="uhf", xc=...)h_uhf.tomlProven—
rohfSCFROHF; ROKS with [dft] functional✓✓ analytic (+ D3(BJ) or MBD@rsSCS gradient with [dft] dispersion on ROKS)FDrun_rohf, run_frequencies(reference="rohf", xc=...)—Proven—
ksdftSCF/DFTRKS; UKS when multiplicity > 1✓✓ analytic (+ D3(BJ) or MBD@rsSCS gradient with [dft] dispersion, RKS and UKS)FD; with [dft] dispersion (closed shell) FD of the KS + dispersion gradient; refused with grid_prunerun_dft / run_ksdft, run_frequencies(xc=..., dispersion=...)benzene-dfb3lyp.toml, h2-lda-opt.tomlProven—
rimp2MP2RHF; UHF + unrestricted RI-MP2 when multiplicity > 1 (energy only)✓✓ analytic (Z-vector; closed shell only)—run_rimp2 (UHF + UMP2 when multiplicity > 1)water-rimp2.toml; local: water-rimp2-local.toml, alkane8-rimp2-local-direct.tomlProvenExact RI-MP2. With [local] scheme = "amplitude-threshold" (run_rimp2(local=..., eps=...)): Proven (narrow) by its exact limit — at ε = 0 the in-core and integral_direct paths match PySCF DF-MP2 to ≤1.1e-11 Ha (H2O and n-butane, 6-31G and cc-pVDZ); finite ε is a measured error map, not validated; closed shell and task = "energy" only; ε = 0 reproduces exact rimp2; the exact reference and the error against it are opt-in ([local] reference = true, compute_reference=True). integral_direct = true never forms the global 3-index tensor; its measured scaling is under Known limits.
mp3MP2RHF✓——run_mp3water-mp3.tomlProven—
oo-rimp2MP2RHF; UHF + unrestricted OO-RI-MP2 when multiplicity > 1 (energy only)✓——run_oo_rimp2 (closed shell only)water-oo-rimp2.tomlProven (narrow)Energy matches an independent numpy OO-RI-MP2 to 7.5e-13 Ha (closed-shell H2O and NH3, UHF CH3 / cc-pVDZ) and the closed-shell analytic gradient matches a finite difference of its own energy to 8e-9 Ha/Bohr. ORCA 6.1.1 stops 3.7e-8 to 7.0e-8 Ha above the same minimum.
att-rimp2MP2RHF✓——run_attenuated_rimp2water-attmp2.tomlProven—
mp2-vMP2RHF; UHF when multiplicity > 1 (energy only)✓——run_mp2_v (closed shell only)water-mp2v.tomlSmokeThe VV10 half is bit-identical to the ωB97X-V path. No comparison to any published MP2-V number; no published MP2-V total energy exists to compare against. The defaults are fitted for aug-cc-pVTZ with frozen core and no counterpoise. Open shell is doubly unvalidated.
scs-mp2MP2RHF✓——run_scs_mp2water-scs-mp2.tomlProven—
scs-mp2-2terfcMP2RHF✓——run_scs_mp2_2terfcwater-scs-mp2-2terfc.tomlProvenNeeds the terfc tables (FERRIC_TERF_TABLE_DIR).
laplace-mp2MP2RHF✓——run_laplace_mp2water-laplace-rimp2.tomlProvenMinimax-Laplace denominators on five systems (R = 19.0 to 45.7) against exact DF-MP2 and against a quadrature-isolated reference that separates quadrature error from implementation error. See the Anchors table.
laplace-sos-mp2MP2RHF✓——run_laplace_sos_mp2water-laplace-sos-mp2.tomlnot gradedWith c_os = 1.0 it reproduces the opposite-spin MP2 energy (internal reference).
pdep-rpaRPA/GWRHF, or RKS via [rpa] xc; UHF/UKS when multiplicity > 1 (energy only)✓✓ (closed-shell RHF reference only; [rpa] xc is refused; analytic SCF + FD correlation)—run_pdep_rpa (closed shell only; open-shell U-PDEP-RPA is CLI-only)water-pdep-rpa.tomlProven—
rs-mp2-rpaMP2 / RPARHF✓——run_rs_mp2_rpawater-rs-mp2-rpa.tomlSmokeThe ω→0 and ω→∞ limits reduce exactly to MP2 and MP2+dRPA and are Proven. At production ω it is only marginally benchmarked on one small subset and is unproven on new systems.
gwRPA/GWRHF, or RKS via [rpa] xc; UHF/UKS, or ROHF/ROKS with [gw] reference = "rohf", when multiplicity > 1 (energy only)✓ (QP energies)——run_gw, run_u_gwwater-g0w0-pbe.toml, oh-ugw.tomlSmokeMatches PySCF gw_ac/ugw_ac only at matched settings (G0W0@HF and @PBE ≤1.3e-7 Ha, evGW ≤5.7e-7, ECP ≤2.5e-6, U-G0W0@UHF ≤7e-6 Ha; U-G0W0@UKS/PBE, with Σx − v_xc inside each spin's quasiparticle equation, ≤2.6e-8 Ha; U-G0W0 on a semi-canonicalized ROHF reference in the Anchors; the ROKS reference is not compared) with [rpa] n_quad = 100 and trunc_thresh = 0; the CLI defaults are coarser (the 20-point grid moves the H2O G0W0@PBE HOMO by 13 meV) and truncation is not validated. See the G0W0 anchors.
bse-tdaRPA/GWRHF only (refuses multiplicity > 1)✓ (excitations)——run_bse_tdawater-bse-tda.tomlSmokeThe lowest five singlets match an independent numpy BSE-TDA to 1.8e-10 Ha given the same quasiparticle energies (H2O cc-pVDZ and aug-cc-pVDZ, NH3 and CH2O cc-pVDZ; see anchors). The quasiparticle energies come from the internal G0W0, which matches PySCF only at n_quad = 100, trunc_thresh = 0; the defaults are coarser.
tdhf-static-polarizabilityRPA/GWRKS only ([rpa] xc required)✓ (static α)——run_tdhf_static_polarizabilitywater-tdhf-static-alpha.tomlSmokeStatic α only, and not established: at a physical scissor (0.36 Ha) it is 5.20 a.u. for water/cc-pVDZ against DOSD 9.64 (−46%). The same kernel gives C6 about 63% low. At scissor = 0 it can hard-error on a negative α diagonal; set [gw] scissor to about 0.3–0.4 Ha.
ccsdCCRHF (spin-adapted solver)✓——run_ccsdwater-ccsd.tomlProven—
ccdCCRHF only (refuses multiplicity > 1)✓——run_ccdwater-ccd.tomlProven (narrow)RI-CCD, spin-orbital solver.
ccsd(t)CCRHF only (refuses multiplicity > 1)✓——run_ccsd_twater-ccsd-t.tomlProven (narrow)Spin-adapted CCSD + spin-adapted (T). Prints E_CCSD, E_(T) and the total.
linlccdCCRHF only (refuses multiplicity > 1; open-shell LinLCCD(hh) is library-only)✓——run_linlccdwater-linlccd.toml; local: water-linlccd-local.tomlProven (narrow)No other code implements LinLCCD(hh); the energy matches an independent numpy solve on PySCF density-fitted integrals to ≤1.2e-12 Ha (H2O and NH3 through the CLI's closed-shell path; UHF OH through the library-only open-shell path). With the hole–hole ladder off it reduces exactly to RI-MP2, and with exact integrals its driver terms reproduce canonical MP2; size consistency is checked. [mp2] linlccd_variant (hh, drivers-only, full) applies exact and local. With [local] (amplitude threshold): Proven (narrow); at ε = 0 every variant, full included, matches the independent numpy solve to ≤ 4.4e-13 Ha, as does the exact path.
drpaRPA/GWRHF only (refuses multiplicity > 1)✓——run_drpa, run_drpa_scan (local ε scan)water-drpa.toml; local: water-drpa-local.tomlProven (narrow)Exact dRPA@HF by the drCCD Riccati solve with nothing truncated; equals the canonical plasmon formula (≤1e-12 Ha), full-rank PDEP (pdep-rpa, trunc_thresh = 0), and an independent numpy plasmon dRPA on PySCF integrals (≤3.0e-11 Ha; water and n-butane at 6-31G and cc-pVDZ, n-octane at 6-31G; see Anchors). Its memory grows as no³·nv² and a run that cannot fit is refused before the SCF. With [local] (amplitude threshold, eps or eps_sweep): Proven (narrow) by its exact limit; the finite-ε error is not variational, and on n-octane it is positive and grows with ε at every point measured (Anchors).
wb97x-l-vCC § ωB97X-L-VIts own RKS (wB97X-L-V) reference (refuses multiplicity > 1; open shell is library-only)✓——nonewater-wb97xlv.tomlProven (narrow)E_KS and λ·E_c against PySCF with the paper's parameters plus a numpy LinLCCD(hh) (water, OH / def2-SVP); Be₂ bond energy against the paper's Table 4.
b2plypCC § double hybridsIts own RKS reference✓——run_double_hybrid(kind="b2plyp")water-b2plyp.tomlSpikeWeighted B88+LYP reference. Not compared with any reference code.
dsd-pbep86CC § double hybridsIts own RKS reference✓——run_double_hybrid(kind="dsd-pbep86")—SpikeWeighted PBE+P86 reference. Not compared with any reference code.
tdaRPA/GW § TDDFTRHF (CIS), or RKS via [tddft] xc (refuses multiplicity > 1)✓ (excitations)——run_tddft(method="tda")water-tda.tomlProven (narrow, closed shell)The lowest five roots match PySCF TDA for water, formaldehyde and NH3 at 6-31G and aug-cc-pVDZ with HF, LDA, PBE and B3LYP: at most 6.5e-4 eV (B3LYP), test bar 1e-3 eV. A [tddft] xc with no f_xc kernel (meta-GGA, VV10, range-separated) is refused before the SCF.
tddftRPA/GW § TDDFTRHF (TDHF), or RKS via [tddft] xc (refuses multiplicity > 1)✓ (excitations)——run_tddft(method="casida")water-tddft-pbe.tomlProven (narrow, closed shell)Same comparison of the lowest five roots against PySCF TDDFT, same bar. Same functional refusals as tda.

task = "optimize" is accepted only for rhf, ksdft, uhf, rohf, pdep-rpa and rimp2. task = "frequencies" is accepted only for rhf, ksdft, uhf and rohf. On an open-shell molecule both tasks are accepted only for uhf, rohf and ksdft (UHF/UKS, ROHF/ROKS). Both tasks refuse [dft] grid_prune, and [scf] k_builder = "cosx" except for rhf, ksdft and uhf (closed-shell RHF/RKS and UHF; UKS with COSX is refused before the SCF); a COSX final pass is skipped for them. [dft] dispersion is refused for frequencies on an open-shell reference: the frequency driver threads the correction through the closed-shell SCF only, so it would report the Hessian of the uncorrected surface. The RKS, UKS and ROKS optimizers apply D3(BJ) and MBD@rsSCS (energy and exact gradient); an open-shell task = "energy" single point applies either. Any other combination exits with an error before the SCF runs.

Open shells in the CLI

  • uhf and rohf read [molecule] multiplicity directly. With [dft] functional they run UKS and ROKS, for energies, optimizations and frequencies.
  • ksdft with multiplicity > 1 runs UKS (the uhf route with the functional set). For ROKS use rohf with [dft] functional.
  • rhf refuses multiplicity > 1 and points to uhf/rohf.
  • rimp2 and oo-rimp2 run on the same plain UHF that kind = "uhf" runs, then take unrestricted RI-MP2 (UMP2, as PySCF mp.MP2(uhf)) and unrestricted OO-RI-MP2. [mp2] kappa is refused on an open shell. task = "energy" only: there is no unrestricted MP2 nuclear gradient.
  • pdep-rpa, gw and mp2-v solve UHF with MOM after 5 iterations when multiplicity > 1, for task = "energy" only. For pdep-rpa and gw, setting [rpa] xc makes that reference UKS; gw with [gw] reference = "rohf" uses ROHF (ROKS with [rpa] xc) instead, semi-canonicalized per spin; U-G0W0 on a semi-canonicalized ROHF reference is in the Anchors, the ROKS reference is not compared. mp2-v does not read [rpa] xc and stays UHF.
  • linlccd and wb97x-l-v refuse an open-shell molecule; their open-shell versions are library-only (ferric_cc::linlccd_u::u_linlccd, ferric_cc::double_hybrid::u_solve_wb97x_l_v).
  • ccd, ccsd and ccsd(t) refuse an open-shell molecule, and no open-shell CCD, CCSD or CCSD(T) exists in the library either: every CC solver reads restricted orbitals.
  • Every other kind refuses multiplicity > 1 with an error before any integral is computed.

Python entry points

These are Python functions without a method.kind of their own; some rows also have a CLI route through a TOML section or task, noted in the row. Scope is taken from the binding code and its docstrings. They are not graded individually.

CapabilityPythonScope (verified in code)
Open-shell KS frequenciesrun_frequencies(reference="uhf"|"rohf", xc=...)Setting xc promotes RHF/UHF/ROHF to RKS/UKS/ROKS. FD Hessian. Also CLI: kind = "uhf"/"rohf" with [dft] functional and task = "frequencies".
Transition-state searchrun_saddleP-RFO. Closed shell only (refuses multiplicity ≠ 1). Raises if the start has no negative mode. Costs 2(6N+1) + (steps+1) gradients.
Reaction pathrun_ircBoth IRC branches from a saddle's imaginary mode. Closed shell only.
Geometry optimization (Python)run_optimizeRHF only (no xc argument). Accepts point charges and a field.
QM/MM energy + forcesQmmmSystem, run_qmmmmethod = "rhf"/"uhf"/"rks"/"uks". Link atoms, boundary schemes (keep/delete-host/rc/rcd), Gaussian-smeared charges, Thole polarizable sites, an optional MM force field (MmTopology). See QM/MM. The CLI [qmmm] section covers fixed point charges only.
QM/MM optimizationrun_optimize_qmmmSame four methods. move_mm = "none"/"all"/("within", r)/("residues", [...]). Moving MM atoms requires mm_topology.
Constrained DFTrun_cdft, CdftConstraintUHF, or UKS when functional names a libxc functional other than "HF" (None and "HF", any case, give UHF). Constrained UKS/PBE matches NWChem 7.2.2 (see anchors); UHF-cDFT has only internal checks, because NWChem cannot run Hartree–Fock cDFT. Fragment target is a Becke electron population ("charge": Nα + Nβ; "spin": Nα − Nβ), not a net charge. Raises if the λ loop does not converge. Not graded; see Constrained DFT.
cDFT electron-transfer couplingcdft_couplingWu–Van Voorhis H_ab between two run_cdft states, each with one converged "charge" constraint, on the same geometry, basis, occupations and Hamiltonian. Not graded.
Point charges / uniform fieldpoint_charges=, external_field= on run_rhf/run_uhf/run_rohf/run_dft, run_optimize, run_frequencies, run_saddle, run_irc, run_pdep_rpaBohr and atomic units. run_rhf also takes smeared_charges=. The MP2/CC drivers take no external-potential arguments. The CLI equivalent is [external_potential], where a width makes a charge smeared.
D3(BJ)run_dft(dispersion="d3bj"), run_frequencies(xc=..., dispersion="d3bj"), d3bj_energyAdditive, with parameters fitted per functional. run_frequencies takes it on the closed-shell reference only.
MBD@rsSCSrun_dft(dispersion="mbd"), run_frequencies(xc=..., dispersion="mbd"), mbd_rsscs_energyrun_dft adds it to total_energy from Hirshfeld volume ratios of the converged density (DftResult.volume_ratios), with β published for PBE, PBE0, HSE06. With with_gradient=True it adds the analytic gradient: the exact analytic gradient, including the orbital relaxation of the Hirshfeld volumes (Z-vector; LDA, GGA and hybrid-GGA functionals without VV10). mbd_rsscs_energy is the standalone energy from caller-supplied ratios.
Chargesmulliken_charges, lowdin_charges, hirshfeld_charges, chelpg_charges, resp_chargesTake an RhfResult or DftResult. Mulliken, Löwdin, CHELPG and RESP are documented closed-shell only. hirshfeld_charges uses the CLI's free-atom SCF proatoms, solved with the result's SCF settings; proatom="slater" selects the single-Slater proatom. RESP is a single-stage restrained fit, not multi-conformer RESP.
Electrostatic potentialesp_at_atoms, esp_at_pointsEvaluated exactly from the density. esp_at_points takes (N, 3) points in Bohr.
Polarizability / momentshirshfeld_polarizability, orbital_moments, density_second_moment—
ω tuningtune_omegaRange-separation ω for a named functional.
Conformer statisticsboltzmann_weights, weighted_stats*, ConformerEnsemble—
Integralscompute_eri3, compute_eri3_mo, compute_metric_2c, boys_localize, shell_infoLow-level access.

Polarizable (Thole) embedding is Python and Rust only. IEF-PCM, UHF stability descent and terfc-attenuated MP2 are reachable from both: see [pcm], [scf] stability_descent and [mp2] att_operator in the Input reference.

Anchors

Where numbers are checked, they are checked against external references or exact limits, not against ferric's own earlier output. "Stated agreement" is the measured difference recorded in the repository; "test tolerance" is what the pinning test actually asserts, which is often looser. Proof links to the test file that asserts the row, and, where one exists, the script that generated its reference data; a row with nothing to link is not graded Proven. Proof files named validation_*.rs are #[ignore]d, so an ordinary cargo test skips them; they run in the weekly validation CI job and on demand (cargo nextest run --run-ignored only -E 'binary(/^validation_/)').

CapabilitySystem / basisReferenceStated agreementTest toleranceProof
UHF and ROHF energies, stability-checkedHO2, NO2, CH2 (triplet), allyl / 6-31G, def2-SVPPySCF UHF/ROHF + stability()4.0e-12 Ha (energy); 3e-7 (⟨S²⟩)1e-10 Ha; 1e-6validation_open_shell_scf.rs, gen_uhf_rohf.py
SCF stability: lowest orbital-Hessian eigenvalues and verdict (UHF internal, UKS internal, RHF internal, RHF→UHF external). RhfConfig::check_stability on an RHF/HF run reports BOTH the internal (singlet) verdict on ScfResult::stability and the external (RHF→UHF triplet, rhf_external_stability) verdict on ScfResult::stability_external; the external operator is also checked against the triplet block of ferric's UHF Hessian at the RHF point, an independent constructionN2⁺, OH (UHF), water at r(OH) = 0.9572 and 2.0 Å (RHF), NH2 (UKS/PBE) / 6-31G; H₂ at 2.0 Å / STO-3G (fast tier)PySCF dense gen_g_hop_uhf/gen_g_hop_rhf/hop_rhf2uhf Hessians + stability()eigenvalues ≤ 4.5e-10 Ha; energies ≤ 7.1e-12 Ha; the dedicated triplet operator matches the UHF-at-RHF triplet block to ≤ 2.3e-13 Ha (two independent constructions); stable/unstable verdicts match PySCF, including water at 2.0 Å where internal is STABLE (+1.97e-2) and external UNSTABLE (−3.07e-1); OH (an exact zero mode) is reported MARGINAL by ferric where PySCF reports stable. KS references are refused for the external channel (no triplet XC kernel f_αα − f_αβ)1e-8 Ha; 1e-10 Ha; 2e-12 Havalidation_scf_stability.rs, scf_stability_external.rs, gen_scf_stability.py
SCF convergence ladder with RHF internal stability descent (solve_rhf_ladder, check_stability + scf_stability_descent): converged energy, internal (singlet) and external (RHF→UHF) Hessian eigenvaluesN2 at 1.60 Å, CuCN, Cr(CO)6 / def2-SVP (Cu, Cr from Basis Set Exchange)PySCF RHF .newton() from four guesses; dense singlet and triplet orbital Hessians from MO integrals, checked against PySCF's own operatorsenergy ≤ 8.8e-10 Ha; eigenvalues ≤ 1.3e-9 Ha; the plain-DIIS rung lands on the N2 saddle 1.906e-2 Ha higher1e-8 Ha; 1e-8 Havalidation_scf_ladder.rs, rhf_stability_descent.rs, gen_scf_ladder.py
RHF and UHF with def2 ECPs: energies and analytic gradientsHI, CH3I, RbH, I atom (UHF) / def2-SVP, def2-TZVP; SnH4 / def2-TZVP; HBr (all-electron control)PySCF RHF/UHF + analytic gradient, and ORCA RHF (NoRI), both fed ferric's basis and ECP≤ 2.9e-8 Ha (energy, both codes); ≤ 2.3e-8 Ha/Bohr (gradient); all-electron HBr 1e-112.5e-7 Ha; 1e-7 Ha/Bohrvalidation_ecp.rs, gen_ecp.py, gen_ecp_orca.py
RHF, UHF, RKS, UKS, ROKS and RI-MP2 nuclear gradients with def2 ECPs: full gradient, the ECP term alone (HF and KS; for RI-MP2 the ECP term is tested through the full gradient and a control that the HF-density ECP term misses), translation invariance, 5-point finite difference of ferric's own energyHF: HI, CH3I / def2-SVP, def2-TZVP; SnH4 / def2-TZVP; CH2I (UHF doublet) / def2-SVP, def2-TZVP; HBr (all-electron control). KS/PBE: CH3I (RKS), CH2I (UKS, ROKS) / def2-SVP. RI-MP2: CH3I / def2-SVP, def2-svp-rifitHF: PySCF analytic gradient with its ECP term separated (checked against a fixed-density finite difference), and ORCA EnGrad (NoRI), both fed ferric's basis and ECP. KS: PySCF with exact J, the matched (75,110) Becke grid and grid_response=True (analytic for RKS/UKS, 5-point finite difference of the ROKS energy). RI-MP2: 5-point finite difference of the PySCF DF-MP2 energy, its ECP term from the relaxed-energy derivative with respect to a λ dV_ECP/dR hcore perturbation, and ORCA RI-MP2 EnGradHF: gradient ≤ 1.3e-7 Ha/Bohr vs PySCF and ≤ 2.5e-7 vs ORCA; the ECP term alone ≤ 1.3e-7; own 5-point FD ≤ 1.9e-7; energy ≤ 2.9e-8 Ha. KS: energy ≤ 2.0e-8 Ha, gradient ≤ 1.4e-8 Ha/Bohr, the ECP term alone ≤ 1.1e-8, own FD ≤ 4.3e-8. RI-MP2: E_corr 7.4e-10 Ha; gradient 7.7e-9 Ha/Bohr vs the PySCF finite difference and 4.3e-8 vs ORCAHF: 5e-7 Ha/Bohr (PySCF), 1e-6 (ORCA), 6e-7 (FD). KS: 2e-7 (PySCF), 6e-7 (FD). RI-MP2: 1e-7 (PySCF FD), 5e-7 (ORCA)validation_ecp_gradient.rs, validation_ecp_rimp2_gradient.rs, gen_ecp_gradient.py
ESP at the nucleiH2O, CH3OH (RHF), HO2 (UHF) / cc-pVDZ, def2-SVPPySCF int1e_rinv on PySCF's converged density, fed to ferric in ferric's AO order (AO order checked through the overlap matrix)4.8e-14 a.u. same density; 4.6e-9 own SCF5e-13 a.u.; 5e-8 a.u.validation_density_properties.rs, gen_properties.py
Electric field at the nucleiH2O, CH3OH, HO2 / cc-pVDZ, def2-SVPPySCF int1e_iprinv, same density (checked against a finite difference of the ESP)1.5e-13 a.u. same density; 2.7e-9 own SCF1e-12 a.u.; 3e-8 a.u.″
Becke effective volumesH2O, CH3OH, HO2 / cc-pVDZ, def2-SVP; free atoms Z = 1–18 (UKS-PBE; B, C, O, F, Al, Si, S, Cl as the fractional-occupation ensemble) / aug-cc-pVDZnumpy on ferric's grid rebuilt from PySCF's radial, Lebedev and AO values, with ferric's Becke partition; free atoms vs PySCF UKS1.4e-15 rel. same density; 7.1e-9 (molecules), 3.5e-8 (atoms) own SCF; Li, Be, Na, Al 1.5e-6 from ferric's XC density floor (ρ ≤ 1e-10), which the reference does not apply; free-atom energies 3.1e-12 Ha1e-13; 5e-8; 2e-6 (Li, Be, Na, Al)″
Hirshfeld charges (hirshfeld_charges) with the same-basis free-atom SCF proatom (what the CLI and ferric.hirshfeld_charges pass by default) and with the single-Slater proatom (proatom="slater"); free-atom proatom tables (spherically_averaged_proatom)H2O, CO, CH3OH (RHF) / cc-pVDZ, def2-SVP; free H, C, O (UHF) and He (RHF) atoms; HeH promolecule (zero-charge limit)numpy on ferric's grid rebuilt from PySCF's radial, Lebedev and AO values, with PySCF's converged density and PySCF free-atom proatoms tabulated and interpolated as ferric does (scipy cubic spline of ln ρ, an independent build of the interpolant); the same charges on a dense (200, 590) grid and on PySCF's own level-9 grid (agreeing to ≤2.8e-7 e)1.9e-13 e same density; 2.6e-9 e own SCF and own free atoms; ferric's default grid ≤2.0e-4 e from the dense-grid value; the 0.05 Bohr proatom table ≤2.9e-7 e from a 0.005 Bohr one; promolecule 8.7e-6 e1e-10 e; 1e-6 e; 1e-3 e; 5e-5 evalidation_hirshfeld.rs, gen_hirshfeld.py
Hirshfeld effective volumes (atomic_effective_volumes_hirshfeld, the TS C6 volumes), on the atom-centred Becke–Lebedev grid of hirshfeld_volume_grid_config (75 radial × 590 angular — the volume integrand carries an r³ factor and needs a higher Lebedev order than the XC grid's 110); and atomic_effective_volumes_hirshfeld_on_grid on the 0.20 Bohr lattice, which MBD@rsSCS integrates onH2O, CO, CH3OH / cc-pVDZ, def2-SVP (SCF proatom); free H, C, O atoms (no proatom, as the CLI's free-atom denominator)numpy on a dense (200, 590) Becke–Lebedev grid, same density and proatoms; the free-atom weight-one moment ∫ρ r³ against the same grid is the exactness anchor (one atom ⇒ Hirshfeld weight 1); plus numpy on ferric's lattice rebuilt point for point for the lattice entry point1.1e-7 rel. vs the dense grid, same density and own SCF alike; ∫ρ within 2.0e-7 e of N_e on the same grid; CH3OH's two mirror-image methyl H equal to 2.9e-14 rel.; free-atom weight-one moment 8.1e-13 rel. (the 1e-12 Hirshfeld weight floor then removes 1.2e-3 of free H's volume, 3.4e-10 of C's); lattice entry point 3.2e-13 rel. same density1e-6; 2e-6 e; 1e-10; 1e-11; 1e-11″
TS C6 and MBD@TS given the same volume ratios: TS α_eff, ω, pair and molecular C6 (ts_atom_params, ts_dynamic_polarizability, casimir_polder_c6); full-range SCS screened α(iω) and C6 (mbd_screen, mbd_dynamic_polarizability), and the plain Gaussian-damped coupled-oscillator energy (coupled_qho_energy_plain_gg), which checks the oscillator algebra and is not a dispersion energyH2O, CO, CH3OH / cc-pVDZ, def2-SVP; CH4, benzene / cc-pVDZ (Hirshfeld volume ratios); ferric's and pymbd's frequency gridspymbd 0.15.0 / libmbd: free-atom table checked equal for every element used; MBD screening as libmbd scs and energy as libmbd plain screening with Gaussian damping (dip,gg), cross-checked against a numpy build and mpmath dimer closed formsTS ≤ 7.5e-16 rel.; MBD 8.1e-15 against the same A&S erf ferric uses, 5.1e-7 against an exact erf1e-12; 1e-11; 1e-6validation_ts_mbd.rs, gen_ts_mbd.py
MBD@rsSCS energy (mbd_rsscs_energy, mbd_rsscs_energy_from_params): range-separated SCS screening (short-range (1−f)·T_GG), screened α₀, C6, ω and R_vdW, and the long-range coupled-oscillator energy with f·T_bare; free-atom R_vdW table (Z = 1–54); frequency gridH2O, CO, CH3OH / cc-pVDZ, def2-SVP; CH4, benzene / cc-pVDZ (Hirshfeld volume ratios); β = 0.83 (PBE) and 0.85 (PBE0/HSE06), a = 6pymbd 0.15.0 (screening + mbd_energy in Python) and libmbd 0.15.0 (Fortran, variant='rsscs') given identical inputs, which agree with each other to < 1e-10; R_vdW table read back from pymbd vdw_params; grid vs pymbd freq_grid(15)E ≤ 9.5e-12 rel.; screened α₀, C6, ω, R_vdW ≤ 1.5e-14 rel.; grid 3.8e-141e-10; 1e-12; 1e-12validation_mbd_rsscs.rs, gen_ts_mbd.py
MBD@rsSCS nuclear gradient, exact (mbd_rsscs_gradient; on an SCF mbd_rsscs_for_scf, used by [dft] dispersion = "mbd" with task = "optimize" and by run_dft(with_gradient=True)): explicit term at fixed volume ratios; Hirshfeld-volume term with the occupied orbitals held fixed (AOs and proatoms follow the atoms, orthonormality term −½ Tr[V D Sˣ D], centroid-anchored lattice response); orbital relaxation from the KS Z-vector (ferric_scf::zvector_ks: closed-shell for RKS; coupled α/β for UKS, with orthonormality term −Σ_σ Tr[V D_σ Sˣ D_σ]; three-block closed/open/virtual for ROKS, with orthonormality term −Tr[Sˣ W_Q], W_Q = D_α V D_α + P_c V P_c + ½ (P_c V P_o + P_o V P_c))explicit: the 8 systems × 2 β above; SCF terms: H2O / STO-3G (HF and PBE), H2O / 6-31G (PBE, PBE0, HSE06, PBE + RI-J), NH3 / 6-31G (PBE); UKS: OH / STO-3G (PBE), NH2, OH (doublets) and O2 (triplet) / 6-31G (PBE, PBE0, HSE06), OH / 6-31G (PBE + RI-J, PBE0 + RI-JK); anchor: H2O / STO-3G RKS run through the UKS path (PBE, PBE0, HSE06); ROKS: HCO (doublet) and CH2 (triplet) / STO-3G (PBE), HCO, NH2 (doublets), CH2 and O2 (triplets) / 6-31G (PBE, PBE0, HSE06), HCO / 6-31G (PBE + RI-J); anchor: H2O / STO-3G RKS run through the ROKS path (PBE, PBE0, HSE06)explicit vs libmbd 0.15.0 force=True (checked against libmbd's own finite differences to ≤ 4.2e-11) and vs central FD in every position and every α₀, C6, R_vdW input; volume term vs central FD of the volumes on a fixed lattice at fixed D; the unrelaxed gradient vs central FD of the pipeline with the reference occupied orbitals re-orthonormalized at each displaced geometry; the exact gradient vs central FD of the full SCF + MBD pipeline, and its relaxation term vs the difference of the two FDsexplicit 6.2e-16 abs vs libmbd; parameter derivatives ≤ 1.3e-6 rel. (FD); volume term 2.5e-8 abs (Slater proatom), 6.5e-9 (tabulated proatom), both at h = 1e-4; unrelaxed 3.4e-9 (H2O/STO-3G HF); exact 8.0e-10 (H2O/STO-3G PBE), ≤ 3.5e-9 (H2O/6-31G, all four), 2.0e-10 (NH3/6-31G) Hartree/Bohr at h = 1e-3; relaxation term (1.0e-5 H2O, 1.6e-6 NH3) vs FD ≤ 3.5e-9; UKS (h = 3e-5): unrelaxed 3.9e-11 and exact 2.9e-11 (OH/STO-3G, h = 1e-3), exact ≤ 1.9e-9 (6-31G, exact J/K), 6.0e-9 (RI-J), 5.0e-9 (RI-JK), relaxation term (2.7e-6–1.0e-5) vs FD ≤ 6.0e-9; the UKS path on a closed shell reproduces the RKS gradient to 3.0e-14; ROKS (h = 1e-3, re-orthonormalizing closed and open orbitals as one set for the unrelaxed FD): unrelaxed ≤ 2.2e-11 against a closed–open cross term of 7.1e-8–3.2e-7, exact ≤ 2.1e-11 (STO-3G), ≤ 2.1e-11 (6-31G, PBE and PBE0), ≤ 4.5e-10 (HSE06), 7.5e-9 (RI-J), relaxation term (2.7e-6–9.8e-6) vs FD ≤ 7.5e-9, every displaced SCF on the reference state (max|ΔD|/h ≤ 0.39 per Bohr); the ROKS path on a closed shell reproduces the RKS gradient to 2.7e-141e-14; 1e-5 rel.; 1e-6 rel. (Slater), 5e-8 rel. (tabulated); 5e-8; 5e-9; UKS 5e-10, anchor 1e-12; ROKS 5e-10, anchor 1e-12validation_mbd_rsscs.rs, mbd_scf_gradient.rs, mbd_scf_gradient_uks.rs, mbd_scf_gradient_roks.rs, unit tests in dispersion/mbd_rsscs.rs
Harmonic frequencies with dispersion (harmonic_frequencies_with_scf_correction; CLI task = "frequencies" + [dft] dispersion, Python run_frequencies(dispersion=...)): central differences of the KS + dispersion analytic gradient, each from the SCF converged at that displaced geometryH2O / STO-3G, PBE (D3(BJ), MBD@rsSCS); H2 / STO-3G HF with a synthetic harmonic-spring correctionD3: the dispersion Hessian vs 4-point second differences of the D3(BJ) energy, and the frequencies vs those of the KS Hessian plus that independent D3 Hessian; MBD: v·H·v along three fixed directions vs second differences of the full SCF + MBD energy; spring: the closed-form spring Hessian; a no-op correction reproduces the plain FD Hessian bit for bitD3 5.3e-9 Hartree/Bohr² (largest element 1.9e-5), frequencies 9.8e-6 cm⁻¹; MBD 1.5e-6 (0.8% of 2.0e-4, limited by noise in the energy differences; without the orbital-relaxation term 3.2e-5); spring 6.6e-8 at a 1e-3 Bohr step (truncation, 25× smaller than at 5e-3)2e-8; 5e-5 cm⁻¹; 3e-6; 2e-7dispersion_frequencies.rs
Static α (direct RPA with a density-fitted Coulomb kernel, no exchange; not CPHF)H2O / aug-cc-pVDZ; CH3OH / cc-pVDZnumpy linear solve on PySCF int3c2e with the same aux basis4.8e-14 rel. same orbitals; 5.3e-10 rel. own SCF5e-13; 5e-9validation_static_alpha.rs, gen_properties.py
Dynamic α(iω) and Casimir–Polder C6 (direct RPA, density-fitted Coulomb kernel, no exchange), molecular and Becke per-atom (molecular_dynamic_polarizability, pdep_polarizability_becke_dynamic, casimir_polder_c6); per-atom α is the Krishtal–Senet–Van Alsenoy intrinsic polarizability (atom-centred Becke dipole on the bra, analytic molecular dipole on the ket; JCP 125, 034312 (2006)) and the charge-transfer remainder α_CT = α_mol − Σ_A α^A (charge_transfer_remainder[_dynamic]); ω = 0 against the static α, molecular and per-atomH2O, N2 / aug-cc-pVDZ (aug-cc-pvdz-rifit)numpy sum over Casida excitations on PySCF int3c2e with the same aux basis, at ferric's 20 Gauss–Legendre nodes (rebuilt with leggauss); per-atom and α_CT on the copy of ferric's Becke grid, α_CT also as the independent charge-flow form 4 (Σ_A R_A q^A)ᵀ R μα(iω) 4.9e-14 rel. same orbitals; C6 6.2e-14; per-atom 3.4e-14; 5.3e-10 own SCF; α_CT,iso(0) is 27.0% (H2O) and 22.7% (N2) of α_iso; α_CT vs charge-flow form 1.1e-4 / 2.0e-5 (grid error of the lab dipole)1e-11; 1e-11; 1e-10; 5e-9; α_CT 1e-10, charge-flow 1e-3validation_pdep_c6.rs, gen_pdep_c6.py
NPZ export ([rpa] export_npz)water / STO-3G, CLI pdep-rpanumpy.load of the CLI's file against the Python bindingsexact (keys, shapes, dtypes, C order, geometry); per-atom and molecular α origin-independent to 3.4e-13 rel.; alpha_ct = alpha_tensor − Σ_A alpha_atomicexact; 1e-11 rel. under translation; 1e-13test_validation_npz_export.py
NPZ export against PySCF: all 30 keys for the default knobs plus the surface ESP, including the C6 block and the PDEP eigenpotentials; each knob turned off removes exactly its keyswater / cc-pVDZ, CLI pdep-rpa with exact J/Knumpy.load of the CLI's file against PySCF integrals contracted with the exported density and orbitals, and against PySCF RHF; α against the numpy dRPA-RI solve; PDEP eigenpotentials against PySCF's generalized dielectric eigenproblem; TS C6 recomputed from the exported α(iω) and the Tkatchenko–Scheffler free-atom tableintegral level ≤2.8e-14; density/orbital-energy/property chain ≤5.9e-10; α 9.2e-11 rel.; PDEP metric 4.3e-12, projector 2.6e-7; C6 recomputed exactly5e-13; 1e-8 (density), 3e-9; 1e-9; 5e-11, 1e-6; 1e-12validation_npz.rs, gen_npz.py
KS-DFT energies: open-shell UKS and second-row closed shellUKS: NH2, CH3, HO2, O2 (triplet) × PBE, B3LYP, ωB97X-V / 6-31G, def2-SVP; RKS: H2S, HCl, SiH4 × PBE, B3LYP, HSE06 / def2-SVP, def2-TZVPPySCF UKS/RKS + stability(), same grid and density fitting; for UKS ωB97X-V both codes use exact J and range-separated DF-K, whose fitting metrics differ, which sets that functional's larger bar; for RKS HSE06 both use exact J and short-range DF-K in the attenuated metricenergy ≤3.1e-12 Ha (PBE, B3LYP), 4.2e-12 Ha (HSE06), 6e-6 Ha (ωB97X-V); ⟨S²⟩ ≤4.6e-9 (PBE, B3LYP), 1.3e-7 (ωB97X-V)energy 1e-10 Ha (PBE, B3LYP, HSE06), 2e-5 Ha (ωB97X-V); ⟨S²⟩ 5e-8, 1e-6validation_ks_energies.rs, gen_ks_energies.py
KS-DFT energies: ROKS (rohf with [dft] functional)NH2, CH3, HO2, OH (degenerate π hole) × PBE, B3LYP / 6-31G, def2-SVPPySCF ROKS, same grid and density fitting (RI-J; RI-K for B3LYP), six starts each polished by second-order SCF and checked with stability(); PySCF's own ROKS does not converge on OH without that polish, and its converged OH points spread by up to 1.3e-6 Ha (π-hole orientation on the grid)8.0e-13 Ha; OH 8.0e-7 Ha1e-11 Ha; OH 5e-6 Havalidation_roks_energies.rs, gen_roks_energies.py
Meta-GGA energies (SCAN, r2SCAN)RKS: H2O, NH3, H2S, CH4 / def2-SVP, def2-TZVP; RI-J (default) and exact J; UKS: NH2 / def2-SVP, def2-TZVPPySCF RKS/UKS + stability(), same grid, density fitting and XC density floorRKS energy ≤ 4.0e-12 Ha, UKS ≤ 2.4e-13 Ha; frontier orbitals ≤ 1.1e-8 Ha; ⟨S²⟩ ≤ 5.2e-10energy 5e-11 Ha; orbitals 1e-7 Ha; ⟨S²⟩ 5e-9validation_mgga_energies.rs, gen_mgga_energies.py
RSH ω tuning (tune_omega): the objective J(ω) = ε_HOMO(N) + E(N−1) − E(N) (signed; the tuner minimizes |J|) and the tuned ω*H2O, NH3 / def2-SVP, ωB97X-V: J at ω = 0.2, 0.3, 0.4, 0.5, 0.6 Bohr⁻¹ and ω*; N2 / def2-SVP: J at ω = 0.2–0.5 only, because the N2⁺ cation turns UKS-unstable (hole localization) between ω = 0.53 and 0.56, below J's rootPySCF RKS neutral and stability-checked doublet UKS cation with mf.omega overridden, same grid, exact J on both states and range-separated DF-K; ω* from brentq on J. Both states use exact J, as tune_omega does by defaultε_HOMO ≤6.7e-6 Ha (N2 4.8e-7); IP ≤8.8e-7 Ha; J ≤6.3e-6 Ha; ω* ≤2.8e-5 Bohr⁻¹2e-5 Ha; 5e-6 Ha; 2e-5 Ha; 1e-4 Bohr⁻¹validation_rsh_omega.rs, gen_rsh_omega.py
Constrained DFT (Becke charge and spin constraints): E(N) − E_unconstrained, λ, and dE/dN = −λLiH (Li), HF (F), H2O⁺ (O; charge and spin) / 6-31G, def2-SVP, 3–4 targets eachNWChem 7.2.2 cdft ... pop becke, PBE, Becke-partitioned Treutler grid, fed ferric's basis; internal: natural-target limit (λ = 0) and dE/dN = −λunconstrained E 1.0e-8 Ha; E(N) − E_unc 2.0e-7 Ha; λ 1.1e-6 (NWChem, grid limit); dE/dN = −λ to 2.3e-12 Ha (Simpson, def2-SVP)5e-8 Ha; 1e-6 Ha; 5e-6; 5e-11 Havalidation_cdft.rs, gen_cdft.py, NWChem inputs
Constrained DFT state selection at an integer target: which constrained state ferric lands on, seeded from NWChem's orbitals (solve_cdft_uhf_seeded, CdftSeed) and unseeded with and without the stability descentHeNe⁺ (N_He = 2, R = 2.0 Å), LiH⁺ (N_Li = 2.8, R = 3.0 Å) / def2-SVP, two states eachNWChem 7.2.2 cDFT (HF exchange), run both with its native Becke partition and with ferric's He/Ne radius ratio by relabelling the atoms; NWChem orbitals imported into ferric (AO order and the d(m=+1) sign mapped and checked)one-shot energy of NWChem's orbitals ≤ 4.7e-10 Ha; seeded and unseeded states ≤ 7.7e-10 Ha after the quadrature correction; λ 3.2e-5 (HeNe⁺), 2.9e-7 (LiH⁺)1e-8 Ha; 1e-8 Ha; 1e-4validation_cdft_state.rs, gen_cdft_state.py
cDFT-ET coupling (Wu–Van Voorhis, coupling_hab) and its ingredients: determinant overlap, one- and two-electron transition elements, |V|; KS-PBE diabats end to endHe₂⁺ at 2.50/3.00/3.50 Å / def2-SVP, aug-cc-pVDZNWChem 7.2.2 cDFT + et (its MO files recomputed in PySCF, every et number reproduced to ≤ 5.9e-11); the textbook form F = E + λN with its constraint-offset invariancekernel ≤ 5.9e-11 Ha; coupling vs textbook ≤ 2e-18; E − E_unc ≤ 4.0e-7 Ha; λ ≤ 6.6e-6; |S| ≤ 2.8e-3 rel., |V| ≤ 8.3e-4 rel. (both largest at def2-SVP 3.50 Å, where |S_AB| = 3.9e-4 is the smallest of the six points; the absolute S offset is a flat 1.7e-7–1.1e-6 across the row, and V ∝ S so its relative error is smaller than S's everywhere); all six points converge end to end, deepest inner SCF 106 iterations (cap 150)2e-10 Ha; 1e-12; 2e-6 Ha; 3e-5; 4e-3 rel. (S); 2e-3 rel. (V)validation_cdft_et.rs, gen_cdft_et.py
IEF-PCM solver and SCF on PySCF's cavity; ferric's own cavitywater, NH3 / STO-3G, cc-pVDZ; ε = 78.4, 4.7PySCF RHF.PCM() IEF-PCM; its SWIG cavity injected into ferriccharges 1.3e-17, E_pcm 3.5e-18 Ha (solver); total energy 4.8e-12 Ha (SCF); ferric's own cavity (modified-Bondi radii, 110-point spheres) 0.07–0.61% from PySCF1e-11 Ha (solver); 1e-10 Ha (SCF); own cavity ±2%validation_pcm.rs, gen_pcm.py
Geometry optimization, RHF and RKSH2O, NH3, CH2O from distorted starts / RHF and B3LYP 6-31G, PBE cc-pVDZPySCF analytic gradient (KS with grid response) driven to max abs gradient ≤ 1e-6 Ha/Bohr by scipy BFGS; exact J/Kdistances ≤ 2.6e-6 Bohr; angles ≤ 1.1e-4°; optimized energies ≤ 9.3e-13 Ha2e-5 Bohr; 1e-3°; 1e-10 Havalidation_geometry_optimization.rs, gen_geometry_optimization.py
Geometry optimization, UHF, ROHF and UKSHO2, CH3, NH2 from distorted starts / UHF, ROHF, UKS-PBE 6-31GPySCF analytic gradient driven to max abs gradient ≤ 1e-6 Ha/Bohr by scipy BFGS; stability() at start and enddistances ≤ 2.6e-6 Bohr; angles ≤ 1.1e-4°; optimized energies ≤ 9.3e-13 Ha; ⟨S²⟩ ≤ 4.9e-9 (UHF, UKS; ROHF is a pure spin state and is not checked)2e-5 Bohr; 1e-3°; 1e-10 Ha; 5e-8 (⟨S²⟩, UHF and UKS)validation_geometry_optimization.rs, gen_geometry_optimization.py
G0W0@HFH2O / cc-pVDZPublished MOLGW IP (11.97 eV); PySCF gw_ac (IP 12.160 eV)—0.30 eV (IP); 0.20 eV (PySCF LUMO and gap)h2o_g0w0_cohsex.rs
MP3 (RI integrals)H2O, NH3 / cc-pVDZ, def2-SVP; all-electron and frozen coreexact-integral PySCF RHF, then the same aux basis and DF factorization as ferric; two independent numpy MP3 constructions (spin-orbital textbook, closed-shell via PySCF's linear doubles residual), agreeing to 2e-17≤ 7.6e-12 Ha1e-10 Havalidation_cc.rs, gen_cc.py, reference data
RI-CCDH2O, NH3 / cc-pVDZ, def2-SVPexact-integral PySCF RHF, then the same aux basis and DF factorization as ferric; PySCF CCD on the DF integrals≤ 1.9e-11 Ha2e-10 Havalidation_cc.rs, gen_cc.py, reference data
RI-CCSD, spin-orbital and spin-adaptedH2O, NH3 / cc-pVDZ, def2-SVP; HCN / cc-pVDZ; H2O / aug-cc-pVDZ; C2H6 / cc-pVDZ (spin-adapted only)exact-integral PySCF RHF, then the same aux basis and DF factorization as ferric; PySCF CCSD on the DF integrals≤ 4.7e-11 Ha (both solvers)2e-10 Havalidation_cc.rs, gen_cc.py, reference data
CCSD(T), spin-orbital and spin-adapted (T)H2O / cc-pVDZ, aug-cc-pVDZ; HCN / cc-pVDZ; C2H6 / cc-pVDZ (spin-adapted only)exact-integral PySCF RHF, then the same aux basis and DF factorization as ferric; PySCF ccsd_t on the DF integrals(T) ≤ 6.9e-12 Ha; the two (T) codes agree to 8.9e-121e-10 Havalidation_cc.rs, gen_cc.py, reference data
CAS-CI (library only: ferric_ci::run_cas_ci, closed-shell RHF reference, lowest Ms = 0 root): total, core and active-space energiesN2 at r = 1.10 and 2.20 Å, CAS(6,6) / cc-pVDZ; H2O, CAS(4,4) / 6-31G; each also with the window moved down one orbitalPySCF mcscf.CASCI on an exact-integral RHF with the same orbital window, checked against a dense diagonalization of the full active-space Hamiltonian. At 2.20 Å both codes use the symmetric RHF, which is internally unstable toward a symmetry-broken RHF 0.19 Ha lowerE_CASCI ≤ 1.4e-11 Ha; e_core and the active-space energy ≤ 2.6e-10 Ha; E_RHF 2.2e-12 Ha3e-9 Ha (CAS-CI energies), 5e-11 Ha (E_RHF)validation_casci.rs, gen_casci.py
G0W0 quasiparticle energies, HOMO−2 to LUMO+2, @HF and @PBE (and @HF with one frozen core orbital)H2O, NH3, N2 / cc-pVDZ (cc-pvdz-ri), aug-cc-pVDZ (aug-cc-pvdz-rifit); @PBE: H2O / both bases, N2 / cc-pVDZPySCF gw_ac fed ferric's basis and aux, with the same 100-point frequency grid, Padé nodes, full quasiparticle equation and exact-integral SCF; @PBE references apply ferric's XC density floor (ρ ≤ 1e-10) and evaluate the Padé as the standard Thiele fraction, because PySCF's pade_thiele_ndarray applies its last coefficient twice (up to 7.1e-3 Ha at @PBE)@HF ≤ 1.3e-7 Ha, @PBE ≤ 6.2e-8 Ha; Σc(ef + iω) at the Padé nodes ≤ 8.6e-11 Ha; Σx ≤ 4.9e-9 Ha1e-6 Ha (Σc(iω) 1e-9)validation_gw.rs, gen_gw.py
G0W0@HF with ECPsI2, Xe, Ag2 / aug-cc-pVDZ-PP (def2-tzvp-rifit)PySCF gw_ac, same basis, inline ECP and aux≤ 2.5e-6 Ha (ε_mf 2.9e-6 from the ECP SCF offset)2e-5 Ha″
U-G0W0@UHFOH, CH3, NH2 / cc-pVDZ, aug-cc-pVDZ; O2 and CH2 triplets / aug-cc-pVDZPySCF ugw_ac on a stability-checked UHF, with ferric's per-spin Fermi level≤ 8.7e-7 Ha; OH/cc-pVDZ 7.0e-6 Ha (its π hole makes the UHF marginally stable)3e-5 Ha″
U-G0W0@UKS/PBE (gw with [rpa] xc and multiplicity > 1)OH, CH3, NH2 / cc-pVDZ (cc-pvdz-ri)PySCF ugw_ac Σc(ef + iω) on a stability-checked exact-J UKS/PBE with ferric's per-spin XC density floor and per-spin Fermi level; standard Thiele continuation and the quasiparticle equation solved in numpy with the density-fitted Σx, because PySCF's ugw_ac vhf_df exchange has the wrong signΣc(ef + iω) ≤8.7e-9 Ha, v_xc ≤7.1e-10 Ha, quasiparticle energies ≤2.6e-8 Ha on orbitals whose quasiparticle equation has one root (NH2's α HOMO and HOMO−1 have three within 1 Ha and are not compared); ferric solves each spin's quasiparticle equation with Σx − v_xc inside it, as the reference does; adding the shift after an unshifted solve lands 0.38–0.96 eV away, and the test requires ferric to miss that rootΣc(ef + iω) 1e-7 Ha; v_xc 1e-8 Ha; quasiparticle energies 3e-7 Havalidation_gw.rs, gen_gw.py
COHSEX (@HF and @PBE), evGW₀, evGW (@HF)COHSEX: H2O, N2 / cc-pVDZ; evGW₀, evGW: H2O / cc-pVDZnumpy COHSEX on PySCF's density-fitted integrals; @PBE on the exact-J RKS/PBE orbitals with ferric's XC density floor, plus the static Σx − v_xc from PySCF's density-fitted exchange and v_xc; anchored to U-COHSEX on the same RKS orbitals as UKS (1.1e-15 Ha); evGW₀ and evGW by iterating PySCF's get_sigma with ferric's update ruleCOHSEX@HF 9.4e-10 Ha; COHSEX@PBE 1.2e-9 Ha; evGW₀/evGW 5.7e-7 Ha1e-8 / 1e-8 / 5e-6 Ha″
U-COHSEX@UHF (static, HF reference, spin-summed W)OH, CH3, NH2 / cc-pVDZ (cc-pvdz-ri), aug-cc-pVDZ (aug-cc-pvdz-rifit); O2 and CH2 triplets / aug-cc-pVDZ; H2O / cc-pVDZ singlet UHF against the closed-shell COHSEXnumpy U-COHSEX on PySCF's density-fitted integrals with ferric's aux, W from ε = I + Π_α + Π_β, on the stability-checked UHF of the U-G0W0 row; the singlet UHF numpy reproduces the closed-shell COHSEX reference to 2.1e-9 Haε_qp ≤ 3.3e-9 Ha (open shell); the singlet UHF through run_u_gw equals closed-shell COHSEX to 8.1e-103e-8 Ha (anchor 1e-8)validation_u_cohsex.rs, gen_u_cohsex.py, reference data
U-RPA, U-G0W0 and U-RI-MP2 (doubles only) on a ROHF reference, semi-canonicalized per spinOH, CH3 doublets / cc-pVDZ (cc-pvdz-ri)PySCF URPA, ugw_ac and a numpy UMP2 (checked against DFUMP2) on the ROHF orbitals semi-canonicalized in numpy (each spin's Fock diagonalized in its occupied and virtual blocks); the ROMP2 singles term is not included on either sideU-RPA E_c ≤ 7.3e-12 Ha, U-MP2 E_c ≤ 8.7e-12, U-G0W0 QP ≤ 1.5e-5 (OH β) and ≤ 1.3e-6 (CH3); semicanonical orbital energies ≤ 6.4e-101e-10 Ha (E_c), 5e-5 Ha (QP)validation_rohf_reference.rs, gen_rohf_semicanonical.py, reference data
BSE-TDA singlet excitation energies and oscillator strengths (G0W0@HF, static W, frozen core 0)H2O / cc-pVDZ (cc-pvdz-ri), aug-cc-pVDZ (aug-cc-pvdz-rifit); NH3, CH2O / cc-pVDZ; lowest five singletsnumpy BSE-TDA on PySCF density-fitted integrals with ferric's aux, W from the static RPA dielectric on HF energies; the kernel is diagonalized on ferric's own quasiparticle energies, because G0W0 energies of core and high virtual orbitals move by up to 0.29 Ha under a 1e-10 relative change of Σc(iω); CIS limit (W replaced by the bare Coulomb interaction, HF energies) against the same numpy build and PySCF tdscf.TDAΩ ≤ 1.8e-10 Ha and oscillator strengths ≤ 2.6e-9 against the kernel on ferric's QP energies; ≤ 2.2e-7 Ha with each side's own QP energies; CIS anchor ≤ 1.6e-9 Ha vs numpy DF-CIS2e-9 Ha (Ω), 3e-8 (f), 2e-6 Ha (own QP)validation_bse.rs, gen_bse.py
TDA and Casida TDDFT excitation energieswater, formaldehyde, NH3 / 6-31G, aug-cc-pVDZPySCF tddft.TDA/TDDFT, same RI and gridHF ≤ 2e-6 eV; LDA/PBE ≤ 2e-5 eV; B3LYP ≤ 6.5e-4 eV1e-3 eVvalidation_tddft.rs, gen_tddft_refs.py
RI-MP2 analytic gradient (all electrons, exact-J/K RHF)distorted H2O, bent HCN / cc-pVDZ (cc-pvdz-ri); distorted NH3 / def2-SVP (def2-svp-rifit)ORCA RI-MP2 NoRI NoFrozenCore EnGrad with the same /C aux; PySCF DFMP2 5-point finite differences≤ 2.1e-9 Ha/Bohr (PySCF FD); ≤ 5.1e-8 Ha/Bohr (ORCA, its own floor); energies ≤ 1.3e-10 Ha2e-8 Ha/Bohr (FD), 2.5e-7 Ha/Bohr (ORCA); 1e-9 Havalidation_rimp2_gradient.rs, gen_rimp2_gradient.py
COSMO solvation (RHF, UHF)H2O, NH3, CH3OH, acetate(−) / STO-3G, cc-pVDZ × ε 4.7, 78.4; HO2 (UHF) / cc-pVDZPySCF pcm.py COSMO driver with ferric's radii, 110-point cavity and point-charge potential (ferric's model); stock PySCF COSMO for the formulation gapsolvated energy ≤ 1.9e-11 Ha, E_cosmo ≤ 9.7e-11 Ha (ferric's model); 0.05–0.51% from stock PySCF COSMO (point vs Gaussian surface charges)1e-10 Ha; 1e-9 Ha; 2%validation_cosmo.rs, gen_cosmo.py
Attenuated RI-MP2 (erfc on 3-center and metric)H2O, NH3 / aug-cc-pVDZ + aug-cc-pVDZ-RIFIT, ω 0.2, 0.222, 0.42, 1.0 Bohr⁻¹numpy RI-MP2 on PySCF int3c2e/int2c2e under with_range_coulomb(-ω)E_corr ≤ 5.9e-12 Ha at every ω; ω → 0 reproduces Coulomb RI-MP2 to 1.6e-151e-10 Havalidation_attenuated_mp2.rs, gen_attenuated_mp2.py
QM/MM electrostatic embedding (RHF): energy, embedding shift, QM gradient, MM forcesH2O + 10 charges / cc-pVDZ, aug-cc-pVDZ; CH3OH + 501 TIP3P charges / 6-31GPySCF qmmm.mm_chargeenergy and shift ≤ 4.8e-12 Ha; QM gradient ≤ 1.2e-10, MM forces ≤ 4.4e-12 Ha/Bohr (up to 501 charges)5e-11 Ha; 1e-9, 5e-11 Ha/Bohrvalidation_qmmm.rs, gen_qmmm.py
Gaussian-smeared MM charges (width in Bohr, ζ = 1/width², same as PySCF radii with unit="Bohr")H2O + 10 charges / cc-pVDZ, widths 0.5, 1, 2 BohrPySCF qmmm.mm_charge(radii=)energy ≤ 4.1e-12 Ha; QM gradient ≤ 1.2e-10 Ha/Bohr; width → 0 reproduces point charges to 3.0e-12 Ha5e-11 Ha; 1e-9 Ha/Bohr″
KS QM/MM (exact J/K, matched (75,110) grid; gradient with grid response)CH3OH + 20 charges / def2-SVP, B3LYP and PBEPySCF qmmm.mm_charge on dft.RKS, grid_response=Trueenergy ≤ 4.1e-12 Ha; QM gradient ≤ 2.9e-9, MM forces ≤ 1.8e-10 Ha/Bohr1e-10 Ha; 3e-8, 2e-9 Ha/Bohr″
Thole polarizable embedding (induced point dipoles, exponential Thole damping a = 2.1304): energy, E_pol, induced dipoles, QM gradient, MM rowsH2O + 4 TIP3P waters (α O 0.837, H 0.496 ų; with and without intramolecular exclusions) / cc-pVDZ, RHF; NH2 + 3 waters / cc-pVDZ, UHF; anchors: one site 30 Å away (classical limit −½α|E0|²), α → 0 (PySCF qmmm.mm_charge)numpy induction model on PySCF point-dipole field integrals and qmmm.mm_charge, self-consistent in the SCF; gradients by 5-point finite differencetotal energy ≤ 3.7e-12 Ha, E_pol ≤ 1.5e-9, induced dipoles ≤ 1.5e-9 a.u., QM and MM gradients ≤ 3.0e-9 Ha/Bohr; far-site E_pol within 7.7e-8 (relative) of −½α|E0|²5e-11 Ha (total), 1e-8 (E_pol, dipoles), 3e-8 Ha/Bohrvalidation_thole.rs, gen_thole.py
MM force field (ferric-mm): bond, angle, torsion, Coulomb and LJ energies and gradients, exclusion and 1-4 pair setsALA-ALA, ACE-PHE-NME, TRP-PRO-ASP (charge −1), ACE-PHE-NME + 3 TIP3P waters, at the relaxed geometry and two random displacements; one extra case with every torsion phase shifted by 37°OpenMM Reference platform (double precision), ff14SB and flexible TIP3P, no cutoff; Coulomb and LJ split by evaluating copies of the nonbonded force with epsilons or charges zeroedenergies ≤ 1.75e-12 relative, gradients ≤ 8.5e-11 relative (after OpenMM's Coulomb constant, which differs from ferric's by 6.6e-11 relative); pair sets identical1e-11 (energy), 1e-9 (gradient), relativevalidation_mm.rs, gen_mm.py
External potential in the RI-MP2 analytic gradientH2O + 10 charges / cc-pVDZ; CH3OH + 20 charges / 6-31G; aux cc-pVDZ-RI5-point finite difference of PySCF DFMP2 on qmmm.mm_charge RHF, same auxenergies ≤ 8.4e-12 Ha; RI-MP2 gradient ≤ 4.8e-9 Ha/Bohr vs PySCF FD1e-10 Ha; 3e-8 Ha/Bohrvalidation_qmmm_mp2_gradient.rs, gen_qmmm.py
κ-regularized RI-MP2 (κ = 0.5, 1.1, 2.0 Eh⁻¹; opposite- and same-spin parts)H2O, CH4 / cc-pVDZ (cc-pvdz-ri)numpy (1 − exp(−κΔ))² sum on PySCF density-fitted integrals with the same aux basis; κ → ∞ checked against PySCF DFMP2E_corr (OS, SS, total) ≤ 3.3e-12 Ha at κ = 0.5, 1.1, 2.0; κ → ∞ equals RI-MP2 exactly3e-11 Havalidation_kappa_mp2.rs, gen_kappa_mp2.py
AO-Laplace RI-MP2 (compute_mo and compute_ao, n_quad 3/5/7, all electrons)H2O / cc-pVDZ and aug-cc-pVDZ, CH4, s-trans-1,3-butadiene, n-octane / cc-pVDZ (cc-pvdz-ri, aug-cc-pvdz-rifit)TWO numpy references on PySCF density-fitted integrals with the same aux basis: the dense (i,a,j,b) sum with the exact 1/Δ (equal to PySCF mp.dfmp2.DFMP2 to 6.7e-16), and the SAME sum with 1/Δ → Σ_k w_k e^(−t_k Δ) over the identical minimax nodes parsed from minimax.rs, which isolates quadrature error from implementation errorMO ≡ AO ≤ 3.4e-13 Ha; vs the quadrature-isolated reference (implementation error) ≤ 3.1e-11 Ha; vs exact DF-MP2 at n_quad = 7 (quadrature error) 8.0e-9 (CH4, R = 19.0), 4.0e-8 (octane, R = 24.5), 1.5e-7 (butadiene, R = 33.7), 8.3e-8 (H2O, R = 36.4), 2.2e-7 (H2O/aug, R = 45.7) — the error tracks R, and no case approaches the k = 7 table limit R ≤ 10001e-10 Ha (MO ≡ AO); 1e-9 Ha (vs quadrature reference); 2e-6 Ha (vs exact DF-MP2)validation_laplace_mp2.rs, gen_laplace_mp2.py, reference data
Local RI-MP2 (rimp2 with [local] scheme = "amplitude-threshold"), in-core (global and per-pair domain fit) and integral_direct, at ε = 0 with every locality map at its no-op limit; finite ε as a measured error mapH2O, n-butane / cc-pVDZ, 6-31G (cc-pvdz-ri), frozen core 0 and one per heavy atom; finite ε on n-butane / cc-pVDZPySCF DFMP2 with the same aux basis (canonical orbitals, closed-form denominators), checked against a dense numpy sum to 1e-12. ORCA 6.1.1 DLPNO-MP2 (NormalPNO, TightPNO) on n-butane and n-octane / cc-pVDZ is recorded as a ballpark only: it truncates by PNO occupation, not by an amplitude threshold, so the two recoveries are not comparedE_corr ≤ 1.1e-11 Ha at ε = 0 (24 comparisons). At ε = 1e-5, 1e-4, 1e-3 (n-butane, all electrons) ferric recovers 99.98%, 99.67%, 95.0% of DF-MP2 in-core and 99.98%, 99.65%, 95.0% integral-direct with its production maps; ORCA DLPNO-MP2 recovers 99.97% (NormalPNO) and 99.99% (TightPNO) of its own RI-MP21e-10 Ha (ε = 0); finite ε: under-correlation that grows with ε, no tolerancevalidation_lmp2_amplitude.rs, gen_lmp2_amplitude.py
LinLCCD(hh) correlation energyH2O, NH3 (RHF); OH (UHF, stability-checked) / 6-31G, cc-pVDZ (cc-pvdz-ri)numpy exact linear solve of the hole–hole ladder equations on PySCF density-fitted integrals; ladder off checked against PySCF DFMP2/DFUMP2LinLCCD(hh) ≤ 4.4e-13 Ha closed shell, 1.2e-12 Ha OH UHF; ladder off equals RI-MP2 to 1.1e-161e-11 Havalidation_linlccd.rs, gen_linlccd.py
Amplitude-threshold LinLCCD at ε = 0, all three variants (drivers-only, hh, full), global and integral-direct paths, plus exact canonical LinLCCD of each variantH2O, NH3 (RHF) / 6-31G, cc-pVDZ (cc-pvdz-ri)numpy exact linear solves on PySCF density-fitted integrals: hh in the occupied-pair eigenbasis, full (hh + pp ladders) as a Sylvester equation in the joint eigenbasis, each in spin orbitals and spin-adapted (agree ≤ 1.1e-16); no ladder checked against PySCF DFMP2local ≤ 4.4e-13 Ha (drivers-only), 2.8e-13 (hh), 2.0e-13 (full); integral-direct ≤ 3.0e-13; exact canonical ≤ 4.4e-13; at finite ε (NH3/cc-pVDZ, hh) the error is +1.6e-13 at ε = 1e-6, +2.7e-9 at 1e-5, +1.07e-5 Ha at 1e-45e-12, 3e-12, 2e-12 Ha; 3e-12; 5e-12validation_linlccd_amplitude.rs, gen_linlccd.py
ωB97X-L-V components: E_KS (libxc ωB97X-V form with the paper's 18 parameters, VV10 b = 10, C = 0.01), λ·E_c = LinLCCD(hh) under λ·erfc(ωr)/r (λ = 0.6, ω = 0.1), total; Be₂ bond energy E(10 000 Å) − E(r_e)H2O (RKS, exact J and RI-J), OH (ROKS → semicanonical → unrestricted) / def2-SVP (def2-svp-rifit, def2-universal-jkfit); Be₂ / def2-QZVPPDPySCF KS with register_custom_functional_ ext params on ferric's (75,110) grid; numpy LinLCCD(hh) on PySCF density-fitted λ·erfc integrals; ladder off equals λ² × PySCF SR-MP2; Be₂: paper Table 4 (2.3 kcal/mol)E_KS ≤ 1.05e-9 Ha; λ·E_c on the same orbitals 7.2e-14 Ha; total on ferric's orbitals 1.1e-7 Ha; Be₂ 2.299 kcal/mol1e-8; 1e-11; 5e-7 Ha; 0.3 kcal/molvalidation_wb97xlv.rs, wb97x_l_v_be2_bde.rs, gen_wb97xlv.py
OO-RI-MP2 energy and analytic nuclear gradient (all electrons, exact-J/K Fock, RI correlation)distorted H2O, distorted NH3 (RHF, energy and gradient); CH3 doublet (UHF, energy) / cc-pVDZ (cc-pvdz-ri)Energy: an independent numpy OO-RI-MP2 on PySCF integrals (exact J/K, the same aux, minimized to max orbital gradient ≤ 1e-10, anchored to PySCF SCF + DF-MP2 at zero rotation); cross-check: ORCA OO-RI-MP2 NoRI NoFrozenCore with the same /C aux, which stops 3.7e-8 to 7.0e-8 Ha above the minimum (reference/doubles split off by up to 1.5e-5 Ha). Gradient: 5-point finite difference of ferric's own OO energy; loose cross-checks against the finite difference of ORCA's OO energy and ORCA's analytic EnGrad (which misses that finite difference by up to 8e-6 Ha/Bohr)total 7.5e-13 Ha, reference/doubles split 6.4e-10 Ha vs numpy; gradient 8.0e-9 Ha/Bohr vs own FD, 2.0e-7 vs the FD of ORCA's energy1e-11 Ha (total), 5e-9 Ha (split), 2e-7 Ha (vs ORCA); 1e-7 Ha/Bohr (own FD), 1e-6 Ha/Bohr (ORCA FD)validation_oo_rimp2.rs, gen_oo_rimp2.py, ORCA inputs
Unrestricted RI-MP2 (rimp2 with multiplicity > 1): E_corr and the αα, ββ, αβ blocksOH, CH3, NH2, O2 (triplet), HO2 / cc-pVDZ (cc-pvdz-ri); HO2 also with frozen core 2PySCF DFUMP2 with the same aux on a stability-checked exact-integral UHF; αα and ββ split by an independent numpy build anchored to DFUMP2's same-spin and opposite-spin energies to 1e-11 HaE_UHF ≤2.8e-12 Ha; every MP2 energy ≤9.2e-10 Ha (HO2), ≤5.8e-11 Ha elsewhere3e-11 Ha; 1e-8 Havalidation_u_rimp2.rs, gen_u_rimp2.py
OO-RI-MP2 with a def2 ECP: energy and analytic nuclear gradient (core Hamiltonian includes V_ECP; gradient includes the ECP derivative term)HI at 1.75 Å (RHF) / def2-SVP (def2-svp-rifit), gradient on the I atomEnergy: the independent numpy OO-RI-MP2 extended with PySCF's ECP core Hamiltonian (ECP read from ferric's basis JSON), anchored to PySCF RHF + DF-MP2 at zero rotation; gradient: 5-point finite difference of ferric's own OO energyOO total 1.5e-8 Ha (the ECP integral offset the RHF energy also carries); doubles 5.5e-11 Ha; gradient 2.3e-9 Ha/Bohr vs its own finite difference2.5e-7 Ha; 5e-8 Ha/Bohrvalidation_oo_rimp2_ecp.rs, gen_oo_rimp2_ecp.py, zero-rotation anchors in oo_rimp2_ecp.rs
Open-shell PDEP-RPA correlation energy (UHF reference, full rank, spin-summed dielectric)OH, CH3, NH2 (doublets), O2 (triplet) / cc-pVDZ (cc-pvdz-ri); OH / aug-cc-pVDZ (aug-cc-pvdz-rifit); H2O / cc-pVDZ through both the restricted and the unrestricted pathPySCF gw.urpa.URPA (and gw.rpa.RPA for H2O) on the same stability-checked exact-integral UHF, same aux basis and the same 40-point Gauss–Legendre frequency grid; cross-checked by numpy on PySCF's density-fitted integrals; truncation checked against a numpy emulation≤ 9.2e-11 Ha (OH/aug-cc-pVDZ); truncated 6.4e-11; U path vs R path at the closed-shell limit 3.2e-121e-9 Ha (U vs R 3e-11)validation_urpa.rs, gen_urpa.py
dRPA@HF by the drCCD Riccati solve (drpa, exact) and its local approximation ([local] amplitude threshold); closed shell, frozen core 0, 1 and (n-butane) 4H2 / STO-3G (STO-3G aux); H2O, n-butane / 6-31G, cc-pVDZ (cc-pvdz-ri); n-octane / 6-31G (cc-pvdz-ri), carbon cores frozen, ε = 0 to 1e-3numpy plasmon dRPA on PySCF density-fitted integrals with the same aux basis and an exact-J/K RHF, Fock semicanonicalized, Ω² from a symmetric eigensolve; the formula and its frozen cores match PySCF gw.rpa.RPA on the same integrals (≤ 3.6e-11 Ha at 160 frequency points) and the H2 value matches the proof notebook's −0.0126072623 (3.7e-11)ε = 0: ≤ 3.0e-11 Ha on the CLI's exact route, the integral-direct route and the in-crate canonical plasmon reference. n-octane, ε = 1e-6 / 1e-5 / 1e-4 / 1e-3: error +6.7e-7 / +3.9e-5 / +5.5e-4 / +1.0e-2 Ha (less correlation) at 82 / 50 / 18 / 2.8% of amplitudes kept; there is no external finite-ε reference3e-10 Ha (ε = 0)validation_drpa_amplitude.rs, gen_drpa_amplitude.py; references in testdata/reference/validation/drpa_amplitude/
Attenuated PDEP-RPA correlation energy (erf and erfc on the 3-center integrals and the metric; closed shell, full rank)H2O, NH3 / cc-pVDZ (cc-pvdz-ri); H2O / aug-cc-pVDZ (aug-cc-pvdz-rifit); ω 0.2, 0.222, 0.42, 1.0 Bohr⁻¹numpy dRPA on PySCF int3c2e/int2c2e under with_range_coulomb(±ω), the same metric factorization as ferric (eigh with a 1e-10 cutoff for erf, Cholesky for erfc), the same RHF and the same 40-point Gauss–Legendre grid; the Coulomb kernel matches PySCF gw.rpa.RPA to 1.6e-14 Ha; erfc(ω → 0) and erf(ω → ∞) reproduce Coulomb RPA≤ 5.8e-11 Ha (erf), 3.5e-11 (erfc), 4.5e-11 (Coulomb); the ω → 0 erfc and ω → ∞ erf limits equal Coulomb RPA to 4.9e-111e-9 Ha (erf), 5e-10 (erfc, Coulomb, limits)validation_attenuated_rpa.rs, gen_attenuated_rpa.py
RS-MP2-RPA correlation energy, formulations B (DeltaLr: E_MP2[Coulomb] + E_dRPA[erf] − 2·E_OS[erf]) and T (CoupledRings: E_MP2[Coulomb] + ΔdRPA[Coulomb] − ΔdRPA[erfc]); every reported componentH2O, NH3 / cc-pVDZ (cc-pvdz-ri); H2O / aug-cc-pVDZ (aug-cc-pvdz-rifit); ω 0.222 (= 0.420 Å⁻¹, the default) and 0.42 Bohr⁻¹numpy assembly on PySCF int3c2e/int2c2e under with_range_coulomb(±ω): RI-MP2 spin components and dRPA from the same fitted integrals ferric uses per operator (eigh with a 1e-10 cutoff for erf, Cholesky otherwise), 40-point Gauss–Legendre grid; Coulomb pieces match PySCF DFMP2 to 3.3e-16 Ha and gw.rpa.RPA to 1.7e-14 Ha; the frequency-integrated second-order ring term equals 2·E_OS to 3.2e-15 relative; exact limits B, T(ω → 0) = MP2 and B, T(ω → ∞) = MP2 + ΔdRPA[Coulomb]every component ≤ 4.7e-11 Ha (MP2 pieces 4.2e-11, dRPA pieces 2.9e-11, B/T e_corr 4.3e-11); ω → 0 and ω → ∞ limits to 4.3e-115e-10 Havalidation_rs_mp2_rpa.rs, gen_rs_mp2_rpa.py
Closed-shell RPA nuclear gradient (total_rpa_gradient: central finite difference of E_RHF + E_c^RPA, exact-J/K RHF reference, full rank; all electrons and one frozen core orbital)distorted H2O, distorted NH3 / cc-pVDZ (cc-pvdz-ri)finite differences of PySCF exact-integral RHF + gw.rpa.RPA with the same aux basis and the same 40-point Gauss–Legendre frequency grid (5-point stencil step-converged to 7.4e-10 Ha/Bohr, and the 3-point stencil at ferric's step); 5-point finite difference of ferric's own energy; controls: RHF gradient, E_c-only gradient, all-electron vs frozen core, 6- vs 40-point gridE_c ≤ 1.5e-13 Ha; gradient ≤ 1.9e-9 Ha/Bohr vs PySCF at the same 3-point step, ≤ 5.7e-8 vs a converged 5-point FD (the 3-point truncation); sum over atoms ≤ 4.8e-91e-12 Ha (E_c); 2e-8 (same step), 2e-7 (5-point) Ha/Bohrvalidation_rpa_gradient.rs, gen_rpa_gradient.py
RI-MP2 size-extensivityH2 dimer at large separation2 × monomer2e-12 Ha1e-7 Harimp2_size_extensivity.rs
RHF/UHF/ROHF/KS gradients, including density-fitted J/Kwater, OH, HO2 / cc-pVDZ, 6-31Gfinite differences of the energy; PySCF df.grad1e-7 to 3e-7 Ha/Bohr (FD); ~1e-10 (PySCF)1e-6 Ha/Bohrdf_jk_gradient.rs
Analytic RHF Hessian (skeleton one- and two-electron, overlap/W and CPHF response terms) and its harmonic frequenciesH2O, NH3, CH2O / cc-pVDZ; distorted H2O / def2-SVPPySCF hessian.rhf (analytic, and its skeleton partial_hess_elec + hess_nuc); PySCF finite differences of its analytic gradient; ferric's own gradient differencedtotal Hessian ≤ 1.6e-7 Ha/Bohr² vs PySCF analytic (≤ 8.2e-8 at cc-pVDZ); skeleton ≤ 5.9e-10; frequencies ≤ 6.9e-4 cm⁻¹; each skeleton term vs a finite difference of its gradient piece ≤ 3.4e-81e-6 Ha/Bohr² (total), 1e-8 (skeleton), 1e-2 cm⁻¹validation_rhf_hessian.rs, rhf_hessian_fd.rs, gen_rhf_hessian.py
Analytic UHF Hessian (skeleton one- and two-electron, overlap/W and coupled α/β CPHF response terms) and its harmonic frequenciesOH (²Π), NH2 (²B1), CH2 (³B1) / cc-pVDZ; tilted OH and off-C2v CH2 / STO-3G, 6-31GPySCF hessian.uhf on the lowest internally stable UHF (analytic, and its skeleton partial_hess_elec + hess_nuc); PySCF finite differences of its analytic gradient; ferric's own UHF gradient differenced; ferric's RHF Hessian for a closed-shell UHFtotal Hessian ≤ 3.8e-7 Ha/Bohr² vs PySCF analytic (OH; ≤ 7.7e-8 for NH2 and CH2); skeleton ≤ 1.4e-10; frequencies ≤ 1.4e-4 cm⁻¹; on closed-shell water the UHF Hessian equals the RHF one term by term (response 2.8e-17)2e-6 Ha/Bohr² (total), 1e-8 (skeleton), 2e-3 cm⁻¹, 1e-12 (vs RHF)validation_uhf_hessian.rs, uhf_hessian_fd.rs, gen_uhf_hessian.py
Harmonic frequencies (FD of analytic gradients; Cartesian Hessian and cm⁻¹)H2O, NH3 × RHF, PBE, B3LYP / 6-31G, cc-pVDZ; UHF OH, CH3, HO2, UKS-PBE and ROHF CH3 / 6-31GPySCF same-step FD of analytic gradients (KS with grid response); PySCF analytic hessian.rhf/uhf/rks/uks; thermo.harmonic_analysis with ferric's massessame-step FD: 2.4e-7 Ha/Bohr², 7.5e-4 cm⁻¹; HF vs analytic 2.75e-5 Ha/Bohr², 0.10 cm⁻¹ (the 5e-3 Bohr step's truncation); KS vs PySCF's analytic Hessian differs by 5–35 cm⁻¹ because that Hessian has no grid response2e-6 Ha/Bohr², 5e-3 cm⁻¹ (same-step); 1e-4 Ha/Bohr², 0.5 cm⁻¹ (HF analytic)validation_frequencies.rs, gen_frequencies.py
Meta-GGA gradient, closed shell (SCAN, r2SCAN)H2O, NH3 / 6-31G, def2-SVP; RI-J (default) and exact JPySCF RKS grid_response=True, same grid and density fitting; FD of ferric's own energy≤ 1.3e-9 Ha/Bohr (def2-SVP 5.0e-10); analytic vs own FD 1.0e-91e-8 Ha/Bohrvalidation_mgga_gradients.rs, gen_mgga_gradients.py
Meta-GGA gradient, open shell (SCAN, r2SCAN)UKS: HO2, NH2 / 6-31G; ROKS: NH2 / 6-31GPySCF UKS grid_response=True + stability(); ROKS: central FD of PySCF ROKS energy; FD of ferric's own energyUKS ≤ 6.2e-9 Ha/Bohr; ROKS 2.2e-10 vs the FD; energies ≤ 5.7e-13 Ha3e-8 Ha/Bohr (UKS); 1e-7 (ROKS)validation_mgga_gradients.rs, gen_mgga_gradients.py
COSX exchange, dense-grid limitwater / cc-pVDZdirect K3.3e-7—cosx_k_anchors.rs; see SCF: choosing how exchange is built
COSX SCF energy, (50,110)+fitwater / cc-pVDZdirect K4.9e-6 Ha—″
COSX SCF energy, (50,110)+fitbutane / def2-SVPdirect K1.7e-4 Ha—″
COSX SCF energy, (50,110)+fitbutane / def2-TZVPdirect K1.2e-4 Ha—″
COSX, open shellCH3 doublet / cc-pVDZdirect K1.96e-5 Ha (UHF), 1.97e-5 Ha (ROHF)—k_builder_open_shell.rs
COSX analytic gradient (RHF, RKS, UHF; default overlap fit for RHF/UHF)water / STO-3G, 6-31G; HO2 / STO-3Gfinite differences of the COSX energy1.6e-9 to 4.3e-9 Ha/Bohr1e-6 / 3e-8 Ha/Bohrcosx_gradient.rs
COSX exchange against PySCF and against exact K: fit off with no screening on the identical grid; angular-grid convergence (75,110)→(75,590) with production knobs; analytic gradient with fit on (RHF) and off (B3LYP)water / aug-cc-pVDZ (RHF, B3LYP); butane / def2-SVP (RHF); distorted water, NH3 for gradientsPySCF sgx on ferric's grid recipe (exact J, fit off, no screening); ferric exact-K energy; central FD of the COSX energysame grid ≤ 1.2e-12 Ha at every grid; error shrinks 93x (butane) to 2576x (water) from 110 to 590 points; exact-K vs PySCF ≤ 2.1e-11 Ha; gradient ≤ 1.6e-8 Ha/Bohr1e-10 Ha; ≥ 30x; 1e-9 Ha; 2e-7 Ha/Bohrvalidation_cosx.rs, gen_cosx.py
RIJCOSX energy (RI-J + COSX K): separation into RI-J error + COSX errorwater / 6-31G (HF, B3LYP); HO2 / 6-31G (UHF)the three other corners of (exact or RI) J × (exact or COSX) K, same gridsecond-order cross term, 2.8–5.8 × e_cosx·e_rij (7.8e-10 Ha at (50,110), 2.3e-11 Ha at (75,302))30 × e_cosx·e_rij, or 1e-10 Hacosx_rijcosx.rs
RIJCOSX analytic gradient (RI-J derivative + COSX derivative; flat and pruned grids; fit on and off)water / STO-3G (RHF, B3LYP); HO2 / STO-3G (UHF)central FD of the RIJCOSX energy1.7e-9 Ha/Bohr (RHF), 4.9e-10 (B3LYP, exchange-isolated), 3.6e-9 (UHF)1e-7 / 3e-8 Ha/Bohr″
Pruned COSX grid (prune = "sgx", peaks 194 and 302) against PySCF SGX with sgx_prune on the same radial grid and Becke partition, fit off, no screeningwater / aug-cc-pVDZ; butane / def2-SVP (RHF)PySCF 2.13.1≤ 1.8e-12 Ha, identical point counts1e-10 Havalidation_cosx.rs, gen_cosx_pruned.py
COSX final-grid passwater / STO-3G, 6-31G; HO2 / STO-3Gthe SCF-grid energy (final grid = SCF grid); SCF converged on the final gridbit-identical; within 1.5e-7 Ha on eight molecules (site/src/methods/scf.md)exact; —cosx_rijcosx.rs, cosx_grid_sweep.py
Lebedev 194-point ruleunit sphereexact monomial integrals; PySCF's ruleexact through degree 23; nodes and weights identical to PySCF's1e-12 relativelebedev.rs

A "—" means the repository states no number for that cell. It is left empty on purpose rather than filled with an estimate.

Benchmark sweeps (GW100 and others) are kept in the project's working notes and are not reproduced here. Quoting a benchmark statistic from memory rather than from the record is the kind of unchecked claim this page exists to prevent.

Known limits and negatives

Reported rather than omitted:

  • Analytic Hessians: closed-shell RHF and UHF only (exact J/K, no ECP, no embedding or solvent, basis up to f functions). ROHF has none (PySCF has no ROHF Hessian either). Every other method's frequencies take central finite differences of the analytic gradient.
  • Local MP2: the integral-direct local MP2 (rimp2 with [local] integral_direct = true) has its correlation stage measured at about N1.24 (erfc) and N1.4 (Coulomb) on n-alkanes C20–C48 (6-31G / cc-pVDZ-RI, ε = 1e-4, frozen carbon cores, a calibrated pair gate; fitted to three points). That is one family of molecules in one basis: not shown to be linear, and not measured on 3-D or diffuse systems. Local MP2 without integral_direct still builds the global 3-index tensor and makes no scaling claim. See The MP2 family.
  • Exact dRPA by the Riccati solve is a small-system path: its ring-product plan holds no³·nv² numbers, no times the amplitudes themselves (C12 thrashed, then was killed for memory). The CLI and run_drpa refuse a run that cannot fit before the SCF; full-rank pdep-rpa gives the same energy at far lower memory.
  • Laplace SOS-MP2, AO-sparse variant: the domain truncation is accurate, and the radius it needs grows far more slowly than the molecule (chemical accuracy at 3 to 5 Bohr on n-alkanes C2 to C12, radius/diameter falling from 0.52 to 0.17; a 71-atom drug molecule is within 0.05% at 4 Bohr, about 13% of its diameter). The algebra is still dense, so no speedup is claimed. The regression test sos_ao_sparse_truncation_radius_is_transferable_across_sizes pins the STO-3G butane/octane comparison (12 Bohr exact on both; octane worse at 3 Bohr). The C2-C12 sweep and the drug-molecule figure are measurements, not regression tests.
  • TDHF/RPAx C6: ~63% low regardless of gap. Do not use it for dispersion; its static α is not established either (see the matrix).
  • TDDFT / TDA: closed-shell references only. Functionals without an fxc kernel (meta-GGA, VV10, range-separated) are refused rather than run without it. B3LYP at aug-cc-pVDZ agrees with PySCF to 6.5e-4 eV, 40× worse than PBE in the same basis; the cause is not yet identified.
  • COSX wins at high angular momentum, not at large system size. Measured on one thread at the flat (50,110) grid with the overlap fit:
    • Against LinK on n-alkanes at def2-SVP it is slower at every size measured: 1.59× (C20), 1.09× (C32) and 1.22× (C48), with no trend toward parity. At def2-TZVP on C20 it is faster (0.67×); that is the only triple-zeta size measured.
    • Against exact direct exchange on butane it is 3.7× slower at def2-TZVP (full SCF). At def2-QZVP its K build takes 90 s against about 400 s for the direct build's single J+K sweep, at a relative K error of 5.4e-5.
    • Its K build grows as N1.29–N1.32 between C20 and C48 at def2-SVP.
    • It applies to the Coulomb operator only: a range-separated functional takes its exchange from density-fitted short- and long-range fitters and ignores k_builder = "cosx", with a warning.
    • Its analytic gradient is exact for RHF, RKS and UHF with the overlap fit off, and for RHF and UHF with the default overlap fit, on flat and pruned (sgx) COSX grids. Fitted COSX with a KS functional, UKS and ROHF/ROKS are refused for gradient tasks, before the SCF runs.

Why the distinction is drawn so sharply

A quantum chemistry code can produce a plausible number in many ways that are wrong:

  • a non-converged SCF returned as an ordinary result, because convergence is a flag rather than an error
  • a method missing a physical term it does not mention (for example, TDDFT excitations computed without the fxc kernel)
  • a fallback model silently substituted for one atom in a molecule, changing a partitioning without changing the shape of the output
  • a screening or truncation threshold that happens to be safe for the test system and not for yours

None of these look like failures, and every one of them has occurred in this codebase. The remedy is to grade each capability separately and say which ones are checked against ground truth.

Testing discipline

Some properties are pinned by tests rather than asserted in prose:

  • Bit-identity across thread counts for reductions and permutations: results do not depend on RAYON_NUM_THREADS
  • ERI 8-fold permutational symmetry: verified against the engine, not assumed, since the MP2 code relies on it to compute only ~1/8 of quartets
  • Size-extensivity and rotational invariance of total energies
  • Memory guards in both directions: a starved budget must be refused and an ample budget must still run, because an over-estimating guard is also a bug

New guards are mutation-tested: a deliberate defect is injected and the test confirmed to fail before the guard is trusted. This has caught guards that passed while proving nothing.

Input reference (TOML)

Every key the ferric CLI accepts, section by section.

Unknown keys are a hard error. The config structs in crates/ferric-cli/src/config.rs are #[serde(deny_unknown_fields)], so a misspelled key or section aborts the run before anything is computed. That includes [external_potential], its point charges, and each [[scf.ladder]] rung.

String values go through strict parsers, where an unknown value is an error at load time rather than a default. Most accept any capitalisation; the exceptions are noted per key.

This page is hand-maintained against crates/ferric-cli/src/config.rs at commit 4b64e6ce. Where a default is applied at the point of use rather than in config.rs, it was read from crates/ferric-cli/src/lib.rs at the same commit. If the code and this page disagree, the code wins. For which method.kind values exist and what each supports, see Capabilities and validation.

Units follow the code: [molecule] geometries are Å (XYZ), point charges and cutoffs named *_bohr are Bohr, keys named *_angstrom or documented as Å are Å, and energies are Hartree.

A minimal file:

[molecule]
xyz = "testdata/molecules/water.xyz"
[basis]
name = "cc-pvdz"
[method]
kind = "rimp2"
[mp2]
auxbasis = "cc-pvdz-ri"

[molecule] (required)

KeyTypeDefaultAllowed valuesNotes
xyzstringrequiredpathStandard XYZ in Å, relative to the working directory. Not read when [qmmm] is present (the PQR supplies the geometry).
chargeinteger0With [qmmm], applies to the QM region.
multiplicityinteger1≥ 1Read by uhf, rohf and ksdft (UKS) for every task, and for task = "energy" only by rimp2/oo-rimp2 (UHF + unrestricted RI-MP2 / OO-RI-MP2) and the open-shell path of pdep-rpa/gw/mp2-v: UHF, or UKS when [rpa] xc is set (pdep-rpa, gw), or ROHF/ROKS for gw with [gw] reference = "rohf"; mp2-v stays UHF. Every other kind refuses > 1. See open shells.

[basis] (required)

KeyTypeDefaultAllowed valuesNotes
namestring—a bundled name (case-insensitive)sto-3g, 6-31g, cc-pvdz, cc-pvtz, aug-cc-pvdz, aug-cc-pvtz, aug-cc-pvqz, aug-cc-pvdz-pp, aug-cc-pvtz-pp, def2-svp, def2-tzvp, def2-qzvp, cc-pvdz-f12.
pathstring—path to a Gaussian-94 fileUsed only if name is absent. One of name/path is required.

Auxiliary basis names used elsewhere (auxbasis, df_*_aux) come from the same table: cc-pvdz-ri, cc-pvtz-rifit, aug-cc-pv{d,t,q}z-rifit, def2-svp-rifit, def2-tzvp-rifit, def2-tzvpp-rifit, def2-qzvp-rifit, def2-qzvpp-rifit, def2-universal-jkfit, cc-pvdz-f12-optri. cc-pvdz-rifit is an alias of cc-pvdz-ri; both names load the same set.

[method] (required)

KeyTypeDefaultAllowed valuesNotes
kindstringrequiredrhf uhf rohf ksdft rimp2 mp3 oo-rimp2 att-rimp2 mp2-v scs-mp2 scs-mp2-2terfc laplace-mp2 laplace-sos-mp2 pdep-rpa rs-mp2-rpa gw bse-tda tdhf-static-polarizability ccsd ccd ccsd(t) linlccd drpa wb97x-l-v b2plyp dsd-pbep86 tda tddftAny other value is an error. Smoke- and Spike-grade kinds print a [warning] grade line on stderr; Proven kinds and the ungraded laplace-sos-mp2 print none. rimp2, drpa and linlccd name the method and are computed exactly unless [local] sets a local approximation (see Capabilities and validation).
taskstring"energy"energy optimize frequenciesoptimize: rhf ksdft uhf rohf rimp2 pdep-rpa only. frequencies: rhf ksdft uhf rohf only.

[scf]

Read by every kind, because every kind runs an SCF first.

KeyTypeDefaultAllowed valuesNotes
max_iterinteger100
energy_convfloat1e-3Sanity bound, not a target. Convergence requires ΔP_rms < density_conv, ΔP_max < 10·density_conv and ΔE < energy_conv. ΔE floors on the RI noise, so tightening this can make a density-fitted run hit max_iter.
density_convfloat1e-6The real convergence signal.
diis_sizeinteger8
diisstring"pulay"pulay adiis ediis (case-insensitive)An unknown value is an error when the file is loaded.
diis_switch_threshfloat1e-1Error level at which ADIIS/EDIIS hand over to Pulay. Ignored for pulay.
smearing_sigmafloatnoneHartreeFermi–Dirac smearing width. Absent means integer occupations.
guessstring"minao"minao sad hcore (case-insensitive)"sad" is an alias of "minao" (the MINAO projection guess); the free-atom-SCF SAD guess is not selectable from config. Any other value is an error.
soscfboolfalseEnables the second-order (Newton) step in the SCF tail.
integral_threshfloat1e-12Integral screening threshold.
eri_precisionfloat1e-200 to 1e-8libint primitive-screening precision for the SCF J/K integrals. Omitted: FERRIC_ERI_PRECISION if set, else 1e-20. 1e-14 costs up to 6e-9 Ha in E_J for atoms past Ne; 0 disables primitive screening (1.8–5.7× slower per J/K build).
screeningstring"schwarz"schwarz csb csamcsb is rigorous and never looser than schwarz. csam is not a bound. It is refused for erfc (short-range) operators. See SCF: screening.
k_builderstring"direct"direct link cosxExchange builder. cosx with RI-J active (named, or the Kohn-Sham default) is RIJCOSX: J from RI-J, K from COSX, and the RI-K default is not applied; cosx next to an explicitly named df_k_aux is an error. link is ignored with a warning when DF-J/DF-K is active. Both are ignored with a warning for functionals with no exact exchange and for range-separated functionals. See SCF: choosing how exchange is built.
cosx_gridinline table{ radial = 35, angular = 194, prune = "sgx" }angular ∈ 6/14/26/50/110/194/302/434/590; prune ∈ none sgx nwchemThe COSX SCF grid. A table without prune is flat; with prune = "sgx", angular is the peak order of the pruned rows (50/110/194/302/434/590). Only with k_builder = "cosx"; otherwise it is an error. The inner table is strict.
cosx_final_passbooltrueRe-evaluate exchange once on a larger grid at the converged density and report that energy (the SCF-grid energy is printed and logged too). Without cosx_final_grid the grid is { radial = 50, angular = 302, prune = "sgx" }. Gradient tasks run without it; ROHF/ROKS skips it with a note (an explicit true there is an error). Only with cosx.
cosx_final_gridinline tablenoneas cosx_gridThe final-pass grid; setting it turns the pass on (cosx_final_pass = false with it is an error). RHF/RKS and UHF/UKS only. Only with cosx.
cosx_overlap_fitbooltrueOnly with cosx; otherwise it is an error.
cosx_backendstring"md3c1e"md3c1e cosx-aOnly with cosx; otherwise it is an error. cosx-a is the slower cross-check kernel.
cosx_screen_threshfloat1e-7≥ 0Only with cosx and md3c1e. 0 disables the screen.
cosx_half_transformstring"sparse"sparse denseOnly with cosx.
df_j_auxstringnone; def2-universal-jkfit for ksdft, pdep-rpa, rs-mp2-rpa, gw, bse-tda, tdhf-static-polarizability, tda, tddftaux basis name, or ""RI-J. With neither key set, rhf/uhf/rohf use exact 4-index J/K. df_j_aux = "" selects exact J for the kinds that default to RI-J (the SCF J/K log line then reads RI-JK via with a blank name). Unlike Python's run_dft, the CLI does not accept "exact", "none" or "off": any non-empty value is looked up as a basis name, and an unknown one fails the SCF.
df_k_auxstringas df_j_auxaux basis name, or ""RI-K. Use a JK-fit set. "" selects exact K.
level_shiftfloat0.0HartreeVirtual-block shift. Left at 0 with a meta-GGA functional, the library applies 0.5.
mom_after_iterinteger0Maximum-overlap occupation pinning after this many iterations. 0 = aufbau throughout.
verboseboolfalseOne line per SCF iteration. The CLI's --verbose/-v flag ORs into this.
df_guessboolonTwo-stage SCF: DF first, then exact. It applies only on the closed-shell, non-laddered path (rimp2 and the other correlated kinds). rhf/ksdft use the convergence ladder, which does not compose with it, and lib.rs prints a warning there whenever it is enabled, including by default. Mutually exclusive with an explicit df_increments = true.
df_guess_auxstringdef2-universal-jkfitaux basis nameIt is an error when df_guess is off.
df_incrementsboolfalseDF-corrected incremental Fock SCF. Same scope as df_guess, and warned and ignored on rhf/ksdft.
df_increments_auxstringdef2-universal-jkfitaux basis nameIt is an error when df_increments is off.
check_stabilityboolfalseDiagnostic only: warns if the solution is a saddle point and never fails the run. On an RHF/HF run it reports TWO verdicts — internal (the singlet channel, is this RHF solution an RHF minimum?) and external RHF→UHF (the triplet channel, does breaking spin symmetry lower the energy?) — because a stretched geometry is routinely internally stable and externally a saddle: water / 6-31G at r(OH) = 2.0 Å gives +1.97e-2 and −3.07e-1. An external instability's remedy is to run kind = "uhf", not to re-converge RHF. RKS gets the internal verdict only (no triplet XC kernel, printed as a skip); UHF/UKS get the internal verdict, which already spans the independent α/β rotations. ROHF/ROKS, range-separated and meta-GGA are skipped entirely with a printed reason.
stability_descentboolfalseState selection: at a saddle of the orbital Hessian (UHF, or the RHF singlet channel), follow the downhill eigenvector and re-converge, keeping the lowest state. Turns check_stability on as well. Same as Python run_uhf(stability_descent=True). Only kind = "rhf", "uhf" or "ksdft" with task = "energy"; any other kind or task is an error. A KS functional with no stability verdict (range-separated, meta-GGA) skips the descent with a printed reason. Costs one Davidson per converged solve plus one SCF per descent. See examples/o2-uhf-stability-descent.toml.
ladderarray of tablesbuilt-in laddersee below[[scf.ladder]] rungs. Read only by rhf and ksdft.

[[scf.ladder]] rungs

Each rung overrides the flat [scf] settings. The rungs are walked in order, and the ladder stops at the first converged rung. Unknown keys are an error, as elsewhere.

KeyTypeDefaultAllowed valuesNotes
guessstring"minao"minao sad hcoreAs [scf] guess: "sad" is an alias of "minao", and any other value (including sad-smallbasis) is an error.
level_shiftfloatinherits
max_iterintegerinherits
df_j_aux, df_k_auxstringinherits
stall_windowintegernone
divergence_tolfloatnone
restartboolfalsetrue discards the incoming density.

[dft]

KeyTypeDefaultAllowed valuesNotes
functionalstring"LDA" (for ksdft)an XC name (LDA, PBE, B3LYP, wB97X-V, SCAN, r2SCAN, …) or a libxc nameRead by ksdft. wb97x-l-v ignores it with a warning. RPA/GW use [rpa] xc and TDDFT uses [tddft] xc instead.
grid_prunestring"none"none off flat; nwchem nwchem-like nwchem_likePrunes the main grid only. Accepted only with task = "energy".
grid_radialinteger75> 0Radial points per atom on the main grid. Only on a run with a Kohn–Sham grid, only with task = "energy" (the XC gradient uses the default grid), and not with kind = "gw". Same as Python grid_radial=.
grid_angularinteger1106 14 26 50 110 302 434 590Lebedev order on the main grid. Same scope as grid_radial. An unsupported order is an error. Same as Python grid_angular=.
dispersionstringabsentd3bj, d3(bj), d3bj(<functional>), mbd, mbd(<functional>) (case-insensitive)Only on a Kohn–Sham SCF (kind = "ksdft", or rhf/uhf/rohf with functional). task = "optimize" runs on RKS, UKS and ROKS references; task = "frequencies" is closed-shell only and uses finite differences of the KS + dispersion gradient, so [frequencies] hessian = "analytic" is an error. There is no "off" value; omit the key instead. A functional with no published D3(BJ) fit or MBD@rsSCS β (PBE, PBE0, HSE06) is an error.
lambdafloat0.6Only for wb97x-l-v.
omegafloat0.1Bohr⁻¹Only for wb97x-l-v. Note the unit differs from [mp2] omega.

[mp2]

This section is shared by the whole MP2 family, ccsd, ccd, ccsd(t), linlccd, drpa, the double hybrids and tda/tddft, all of which read auxbasis and frozen_core from here. drpa, linlccd and a local rimp2 read nothing else from it (plus linlccd_variant for linlccd); any other [mp2] key on them is an error.

KeyTypeDefaultAllowed valuesNotes
auxbasisstring"cc-pvdz-ri"; "cc-pvdz-rifit" for tda/tddftaux basis nameThe two defaults name the same bundled set (cc-pvdz-rifit is an alias of cc-pvdz-ri).
frozen_coreint, string or bool0integer ≥ 0, "auto", "none", true (= auto), false (= 0)"auto" gives the standard small core for this molecule after the ECP is applied, and the run prints the resolved count.
omegafloat0.420Å⁻¹att-rimp2 (erfc), rs-mp2-rpa. Ignored with a warning when attenuator = "terf". An error on att-rimp2 with att_operator = "terfc".
kappafloatnoneκ > 0, Hartree⁻¹κ-regularized MP2 for the exact rimp2; an error with [local]. Absent = plain MP2.
c_osfloat1.2 (scs-mp2), 1.27 (scs-mp2-2terfc), 1.3 (laplace-sos-mp2)
c_ssfloat1/3 (scs-mp2), 4.05 (scs-mp2-2terfc)laplace-sos-mp2 warns and ignores it.
n_quadinteger73 5 7laplace-mp2, laplace-sos-mp2. Any other value is an error.
sos_formulationstring"mo"mo ao ao-sparselaplace-sos-mp2. mo and ao are exact and agree to round-off. ao-sparse is approximate and requires domain_cutoff_bohr.
domain_cutoff_bohrfloatnone> 0, BohrRequired by ao-sparse. An error with the other formulations.
formulationstring"delta-lr"delta-lr coupled-ringsrs-mp2-rpa.
attenuatorstring"erf"erf terfrs-mp2-rpa. terf needs FERRIC_TERF_TABLE_DIR. An error on att-rimp2 (use att_operator).
r0float1.6828 (= 3.18 Bohr)Års-mp2-rpa with terf only. An error on att-rimp2 (use att_r0).
terf_omegafloatlinked, ω = 1/(r0√2)Å⁻¹, > 0rs-mp2-rpa with attenuator = "terf" only (an error elsewhere). Sets the terf/terfc sharpness independently of r0. Same as Python run_rs_mp2_rpa(terf_omega=).
att_operatorstring"erfc"erfc terfc (case-insensitive)att-rimp2 only (an error on any other kind). The short-range operator on the MP2 correlation; the SCF stays Coulomb. terfc is the Python run_terfc_rimp2 and needs FERRIC_TERF_TABLE_DIR.
att_r0float1.05Å, > 0att-rimp2 with att_operator = "terfc" only; an error with erfc.
r0_sweeparray of floatsnoneÅ, > 0rs-mp2-rpa with terf only. Reuses one SCF for several r0 values. r0 is then ignored with a warning.
r0_bondedfloat0.75Åscs-mp2-2terfc.
r0_nonbondedfloat1.05Å, > r0_bondedscs-mp2-2terfc.
linlccd_variantstring"hh"hh drivers-only fulllinlccd only (an error on any other kind), exact and local alike: it selects the method. drivers-only equals RI-MP2; full adds the pp ladder (CCD-like VVVV memory). Any other value is an error.
mp2v_r0float1.00Å, > 0mp2-v. Also sets the VV10 damping r0. It is correlated with mp2v_b in the published fit.
mp2v_bfloat11.0mp2-v.
mp2v_cfloat0.0089mp2-v. Fixed in the paper. Changing it leaves the published parameterization.
mp2v_attenuatorstring"terfc"terfc erfcmp2-v. terfc needs FERRIC_TERF_TABLE_DIR. erfc is an unparameterized control.
mp2v_omegafloatlinked, ω = 1/(r0√2)Å⁻¹, > 0mp2-v with terfc only. Setting it leaves the fitted parameterization.
mp2v_vv10_dampingstring"terfc"terfc nonemp2-v. none double-counts short-range correlation.
mp2v_nlc_n_radialinteger50> 0mp2-v VV10 grid.
mp2v_nlc_n_angularinteger50> 0mp2-v VV10 grid (unpruned).
oo_max_iterinteger100≥ 1oo-rimp2 only (closed and open shell). Orbital-optimization iterations.
oo_grad_convfloat1e-4> 0oo-rimp2 only. Convergence threshold on the orbital-gradient norm.
oo_level_shiftfloat0.1Hartree, ≥ 0oo-rimp2 only. Level shift on the approximate diagonal orbital Hessian.
oo_diis_sizeinteger6≥ 1oo-rimp2 only. DIIS subspace for the orbital rotations. The four oo_* keys match Python run_oo_rimp2(max_iter=, grad_conv=, level_shift=, diis_size=); on any other kind they are an error.

[local]

The local approximation of a correlated method. method.kind names the method (rimp2, drpa or linlccd); this section says whether and how its amplitudes are truncated. Without it (or with scheme = "none") the method is computed exactly. On any other kind the section is an error. The local runs are closed shell and task = "energy" only, and every printout and run-log result carries the model: "<method> (exact)" with "local": null, or the scheme, eps and kept fraction. See The MP2 family and Exact and local correlation.

KeyTypeDefaultAllowed valuesNotes
schemestring"none"none amplitude-thresholdamplitude-threshold: drop pair amplitudes whose localized integral is at or below eps (single threshold). Any other value is an error.
epsfloatrequired with amplitude-threshold≥ 0, finiteThe threshold is part of the model and has no default. 0 keeps every amplitude and reproduces the exact method. An error with scheme = "none".
eps_sweeparray of floatsnoneeach ≥ 0drpa only. Several ε on one SCF and one localized assembly; sorted and de-duplicated, one result block per point. Instead of eps, not with it.
referenceboolfalseAlso compute the exact method (canonical RI-MP2, canonical plasmon dRPA, exact LinLCCD) and print the local error against it. Costs the full exact calculation. An error with scheme = "none".
integral_directboolfalserimp2 only. The integral-direct local MP2: never forms the global 3-index tensor.
aux_radiusfloat10.0Bohr, > 0Integral-direct only (an error otherwise). Aux fit-domain radius.
virt_radiusfloat12.0Bohr, > 0Integral-direct only. Virtual domain radius.
ao_tailfloat1e-3≥ 0Integral-direct only. 0.0 keeps every shell.
schwarz_skipfloat1e-5≥ 0Integral-direct only. Must be 0.0 for terfc operators, or the run errors.
batch_mergeinteger4≥ 1Integral-direct only.
gate_calfloatnone (gate off)> 0Integral-direct only. Pair-gate calibration (~0.7 Coulomb, ~0.02 erfc ω = 1).
virt_schwarz_kappafloatnone (off)> 0Integral-direct only. ε-linked Schwarz virtual-candidate screen.

[rpa]

Read by pdep-rpa, gw, bse-tda, tdhf-static-polarizability, and, for trunc_thresh only, rs-mp2-rpa.

KeyTypeDefaultAllowed valuesNotes
auxbasisstring"cc-pvdz-ri"aux basis name
frozen_coreint, string or bool0as [mp2]
xcstringnone (HF reference)XC nameSwitches the reference to RKS, or to UKS when open shell. Required by tdhf-static-polarizability. bse-tda ignores it. pdep-rpa with task = "optimize" uses an RHF reference; xc applies only to task = "energy".
n_quadinteger20 (energy runs); 16 (task = "optimize")The Python run_pdep_rpa uses 40. Set it explicitly for reproducibility.
quadraturestring"gauss-legendre"gauss-legendre gauss_legendre gl; minimax mini-max mm; chebyshev-tan chebyshev_tan chebyshev ct
u0float0.5Warn-and-ignore under minimax, which derives its own u₀.
trunc_threshfloat1e-4; 0.0 (full rank) for rs-mp2-rpaPDEP truncation.
eigensolver_conv_threshfloat1e-6 (energy); 1e-8 (optimize)Alias: davidson_conv_thresh.
chi0_sparsitystring"dense"dense, boys, boys:<thresh>, auto, auto:<cutoff>, auto:<cutoff>:<thresh>, each boys/auto form optionally suffixed @<radius_bohr>
run_diagnosticsboolfalse
export_eigpot_prefixstringnoneWrites <prefix>_eigpot_NNN.cube.
export_eigpot_countinteger10Capped at the number of eigenpotentials.
cube_spacingfloat0.2Bohr
cube_marginfloat4.0Bohr
export_npzstringnonepathTurns on the NPZ property bundle. The compute_* keys below default to true only when this is set.
compute_espbooltrueESP at the nuclei.
compute_esp_surfaceboolfalseESP on a vdW shell.
esp_surface_vdw_scalefloat1.4
esp_surface_n_angularinteger110Lebedev order
compute_polarizabilitybooltruealpha_tensor, the molecular static α.
compute_alpha_atomicbooltruealpha_atomic: the Krishtal–Senet–Van Alsenoy intrinsic per-atom α (JCP 125, 034312 (2006)), always Becke-partitioned; c6_partition does not affect it. Charge transfer between atoms is excluded; with compute_polarizability also on, the remainder alpha_ct = alpha_tensor − Σ_A alpha_atomic is exported.
compute_electric_fieldbooltrue
compute_density_matrixbooltrue
compute_dipolebooltrue
compute_hirshfeld_chargesbooltrueProatoms are free-atom SCF densities in the molecule's basis and SCF settings, as in Python's hirshfeld_charges.
compute_lowdin_chargesbooltrue
compute_mulliken_chargesbooltrue
compute_chelpg_chargesbooltrue
compute_resp_chargesbooltrue
compute_c6booltrueWith c6_source = "pdep", also exports alpha_ct_dynamic (nfreq, 3, 3): molecular α(iω) − Σ_A α^A(iω).
allow_partial_npzboolfalseBy default a bundle missing a requested property fails the run.
c6_sourcestring"ts"ts pdep mbd
c6_partitionstringhirshfeld for pdep, becke for ts/mbdbecke hirshfeld

[gw]

Read by gw. bse-tda and tdhf-static-polarizability read frozen_core, and tdhf-static-polarizability also reads scissor. The [rpa] section supplies the screened interaction.

KeyTypeDefaultAllowed valuesNotes
methodstring"g0w0"g0w0 cohsex evgw0 evgw (case-insensitive)
qp_mos[lo, hi]HOMO−2 … LUMO+2absolute MO indices, half-open
max_ev_iterinteger20evGW/evGW0 outer loop.
ev_conv_threshfloat1e-4Hartree
pade_nptsinteger0 (= [rpa] n_quad)
qp_newton_dampfloat1.0
frozen_coreint, string or boolfalls back to [rpa] frozen_coreas [mp2]Also overrides the PDEP frozen core, so W and Σ agree.
scissorfloat0.0Hartreetdhf-static-polarizability only. At 0.0 some molecules hit a negative α diagonal and the run errors. The remedy is about 0.3–0.4 Ha.
referencestring"uhf"uhf rohf (case-insensitive)gw on an open-shell molecule only: the reference is UHF or ROHF (UKS or ROKS with [rpa] xc). An error on a closed-shell molecule or another kind. Same as Python run_u_gw(reference=).

[tddft]

Read by tda and tddft. Also set [mp2] auxbasis (see above).

KeyTypeDefaultAllowed valuesNotes
n_rootsinteger3
xcstringnone (HF reference: CIS or TDHF)XC nameSelects the reference functional and the f_xc kernel. Meta-GGA, VV10 and range-separated functionals are refused.
c_hffloatthe functional's short-range exact-exchange fraction; 1.0 with no xc

[optimize]

Read when task = "optimize".

KeyTypeDefaultAllowed valuesNotes
max_stepsinteger100
g_max_threshfloat4.5e-4Hartree/Bohr
g_rms_threshfloat3.0e-4Hartree/Bohr
e_convfloat1e-6Hartree
trust_radiusfloat0.1Initial step size.
coordinatesstring"cartesian"cartesian cart; internal internals redundant-internal redundant_internal

[frequencies]

Read when task = "frequencies".

KeyTypeDefaultAllowed valuesNotes
hessianstring"auto"auto; analytic; fd finite-differenceauto uses the analytic Hessian for RHF (closed shell) and UHF (aufbau occupations) with the Coulomb operator and exact four-centre J/K (no RI or COSX), a basis up to f functions and a libint2 with second derivatives; no ECP, external potential, implicit solvent, polarizable embedding, fractional occupations, MOM or constraints. Anything else uses finite differences. analytic is an error where it does not apply. The output prints Hessian = analytic or finite-difference.
deltafloat5e-3Bohr, finite and > 0Central-difference step, finite-difference Hessians only. Check the printed Hessian asymmetry: it is zero in exact arithmetic.

[memory]

KeyTypeDefaultAllowed valuesNotes
budget_gbfloatautofinite and > 0Precedence: this key, then FERRIC_MEM_BUDGET_GB, then the legacy FERRIC_OOC_BUDGET_GB/FERRIC_ERI3_BUDGET_GB, then 0.8 × available RAM, then 2 GiB. A value of 0, a negative value or NaN is an error; omit the key for auto. It bounds the ledgered allocations, not total process memory.
three_index_budget_gbfloat—Deprecated alias. budget_gb wins if both are set.

[output]

KeyTypeDefaultAllowed valuesNotes
jsonstring or bool<input-stem>.ferric.jsonl beside the inputa path, true (the default path), false (off)On by default. See Run logs. --json <path> and --no-json override it.

[qmmm]

When present, the PQR supplies both the geometry and the MM charges. It cannot be combined with [external_potential]; doing so is an error.

KeyTypeDefaultAllowed valuesNotes
pqrstringrequiredpathGeometry in Å and charges in e. The element is read from the atom name.
qm_indicesinteger array[]zero-basedUse this, or qm_seeds + qm_radius_angstrom, but not both.
qm_seedsinteger array[]zero-based
qm_radius_angstromfloatnoneÅ, > 0Requires qm_seeds.
link_bondsarray of [qm, mm][]Required when the cut crosses a covalent bond.
boundary_schemestring"delete-host"keep delete-host rc rcdSetting a non-default value without link_bonds is an error.

See QM/MM. Smeared PQR charges, polarizable sites and MM force fields are Python only. (Smeared charges outside QM/MM are available through [external_potential] with width.)

[pcm]

IEF-PCM implicit solvent (ferric-pcm). Absent means vacuum. Honoured on task = "energy" by rhf, uhf, rohf, ksdft and pdep-rpa (for pdep-rpa the RPA correlation is evaluated on the solvated reference). Any other kind, a gradient task (no gradient has a PCM term), [cosmo] in the same file, or pdep-rpa with [rpa] export_npz is an error. The solvent table and checks are shared with Python run_rhf(solvent=...). See examples/water-pcm.toml.

KeyTypeDefaultAllowed valuesNotes
epsilonfloat—finite and > 1Dielectric constant. Give exactly one of epsilon and solvent.
solventstring—water (78.4) dmso (46.7) methanol (32.6) ethanol (24.9) acetone (20.7) dichloromethane/dcm (8.93) thf (7.43) chloroform (4.71) toluene (2.38) hexane (1.88), case-insensitiveDielectric constants at 298 K. An unknown name is an error.
lebedev_orderinteger1106 14 26 50 110 302Tesserae per atomic sphere.

[cosmo]

Conductor-like implicit solvent, applied to every SCF variant. It cannot be combined with [pcm].

KeyTypeDefaultAllowed valuesNotes
epsilonfloatrequired when the section is presentfinite and > 1Default in code is 78.39, but serde has no default for this key.
radius_scalefloat1.17> 0Multiplies Bondi radii.
lebedev_orderinteger1106/14/26/50/110/302
s_matrix_kindstring"GaussianSmeared"GaussianSmeared PointChargeSerde variant names, case-sensitive.

[external_potential]

Strict, like every other section: an unknown key here or inside a point charge is an error.

KeyTypeDefaultAllowed valuesNotes
point_chargesarray of { q, x, y, z, width }[]q in e; x, y, z in Bohr; width in Bohr, finite and > 0Written as [[external_potential.point_charges]] tables. width is optional: with it the charge is Gaussian-smeared, density ∝ exp(−r²/width²) and potential q·erf(r/width)/r (the Python smeared_charges=); without it the charge is a point. See examples/water-rhf-smeared-charge.toml.
field[Ex, Ey, Ez]noneatomic unitsUniform electric field.

With both empty, the run is identical to a vacuum run.

Python bindings

import ferric gives you the same engine as the ferric CLI, as plain function calls that return result objects holding floats and numpy arrays. To install it, see Installation. The wheel is enough; you do not need a clone of the repository to run anything on this page.

If you already know PySCF, For PySCF users maps the calls you know onto ferric's and lists where the two behave differently.

Every snippet below uses an inline geometry. Run them in order, since later ones reuse water, o2, bs, bs_dz and aux from earlier ones. The snippets through "Properties and charges" were run when this page was written (2026-09-23), and any output shown is what they printed. The one-line calls in the reference tables were not run.

Molecules and basis sets

import ferric

water = ferric.Molecule.from_xyz_string("""3
water
O   0.000000   0.000000   0.117790
H   0.000000   0.755453  -0.471161
H   0.000000  -0.755453  -0.471161
""")
bs = ferric.BasisSet.bundled("sto-3g")

The string is standard XYZ: an atom count on the first line, a comment line, then one symbol x y z line per atom. The count must be the first line, so start the string with """3. Starting it with """ and a newline gives an empty first line, and parsing fails with bad atom count. Molecule.from_xyz(path) reads the same format from a file. A leading @ on a symbol (@H) makes a ghost atom, which carries basis functions but no nucleus or electrons.

Charge and spin belong to the molecule, not to the SCF call:

o2 = ferric.Molecule.from_xyz_string("""2
O2 triplet
O 0.0 0.0 0.0
O 0.0 0.0 1.208
""", charge=0, multiplicity=3)

multiplicity is 2S+1, so a triplet is 3. (PySCF's spin is 2S, so the same triplet there is spin=2.) Both constructors default to charge=0, multiplicity=1. run_uhf and run_rohf take no spin argument; they read it from the molecule. The geometry-changing drivers (run_frequencies, run_saddle, run_irc) also accept a multiplicity= keyword.

Units

QuantityUnit
XYZ input to from_xyz / from_xyz_stringÅngström
Molecule.coords()Ångström
Molecule.coords_bohr()Bohr (ferric stores Bohr internally)
point_charges= / smeared_charges= on run_rhf, run_uhf, run_rohf, run_dft, …Bohr, charges in e
external_field=Hartree atomic units
esp_at_points(..., points)points in Bohr; potential in atomic units
omega on run_attenuated_rimp2, run_rs_mp2_rpaÅ⁻¹ (default 0.420)
r0 on the terfc driversÅ
omega / r0 on compute_eri3_mo, compute_metric_2cBohr⁻¹ / Bohr (raw, unlike the run_* drivers)
QmmmSystem(..., coords_angstrom)Ångström
QmmmSystem.point_charges()Bohr
EnergiesHartree
GradientsHartree/Bohr
print(water.coords()[0])        # (0.0, 0.0, 0.11779)            Ångström
print(water.coords_bohr()[0])   # (0.0, 0.0, 0.22259084021251865) Bohr

The Sharp bits page has the full units table, including the QM/MM accessors.

Bundled basis sets

BasisSet.bundled(name) loads a basis compiled into the library. Names are case-insensitive. An unknown name raises ValueError. These 25 sets are available (cc-pvdz-rifit is also accepted, as an alias of cc-pvdz-ri):

KindNames
Orbitalsto-3g, 6-31g, cc-pvdz, cc-pvtz, aug-cc-pvdz, aug-cc-pvtz, aug-cc-pvqz, def2-svp, def2-tzvp, def2-qzvp
Orbital with ECP (heavy elements)aug-cc-pvdz-pp, aug-cc-pvtz-pp
Explicitly correlated (F12)cc-pvdz-f12 (orbital), cc-pvdz-f12-optri (OptRI auxiliary)
RI (MP2/RPA/CC fitting)cc-pvdz-ri, cc-pvtz-rifit, aug-cc-pvdz-rifit, aug-cc-pvtz-rifit, aug-cc-pvqz-rifit, def2-svp-rifit, def2-tzvp-rifit, def2-tzvpp-rifit, def2-qzvp-rifit, def2-qzvpp-rifit
JK (SCF fitting)def2-universal-jkfit

The list comes from the bundled() match in crates/ferric-core/src/basis.rs. The source records two coverage gaps: cc-pvtz lacks K, and cc-pvtz-rifit lacks K and Ca. An RI run on a missing element errors; it is not silently patched. The Python API has no loader for basis files on disk; the Rust library parses BSE-JSON and Gaussian-94 files (see the Rust API).

Most drivers take a BasisSet object. The drivers that move atoms (run_optimize, run_frequencies, run_saddle, run_irc, run_qmmm, run_optimize_qmmm) take the basis name as a string instead, because they rebuild the basis at every geometry.

Ground state

rhf = ferric.run_rhf(water, bs)
print(rhf)
print(f"RHF: {rhf.energy:.10f} Ha, converged={rhf.converged}")
RHF Energy: -74.9631468000 Ha (converged: true, 8 iterations)
RHF: -74.9631468000 Ha, converged=True

Open shell, using the triplet o2 built above:

bs_dz = ferric.BasisSet.bundled("cc-pvdz")
uhf  = ferric.run_uhf(o2, bs_dz)
rohf = ferric.run_rohf(o2, bs_dz)
print(f"UHF  {uhf.energy:.10f}  converged={uhf.converged}")
print(f"ROHF {rohf.energy:.10f}  converged={rohf.converged}")
UHF  -149.6276689907  converged=True
ROHF -149.6079865946  converged=True

PySCF 2.12 on the same geometry and basis gives −149.6276689907 (UHF) and −149.6079865946 (ROHF). run_rohf returns a UhfResult: it has α and β densities and orbital energies, which coincide for the spatial orbitals.

Kohn–Sham DFT:

dft = ferric.run_dft(water, bs_dz, functional="b3lyp")
print(f"B3LYP {dft.total_energy:.10f}")

run_dft is closed-shell only, and its default functional is LDA. It uses density fitting for Coulomb by default (def2-universal-jkfit); run_rhf does not. Pass df_j_aux="exact" for conventional four-centre Coulomb when you compare against an exact-Coulomb code. dispersion="d3bj" adds Grimme D3(BJ); the result then carries e_scf, e_dispersion and their sum in total_energy. with_gradient=True also returns the analytic nuclear gradient from dft.gradient(). There is no open-shell run_dft. Unrestricted Kohn–Sham is reachable from Python only inside other drivers: run_frequencies(reference="uhf", xc=...), run_u_gw(xc=...), run_qmmm(method="uks") and run_cdft(functional=...).

run_rhf also takes implicit solvent (solvent=78.4 or solvent="water", IEF-PCM), point charges and a uniform field. See its docstring (help(ferric.run_rhf)) for the full SCF knob set, which matches the CLI [scf] section.

Check .converged, and know what it means

run_rhf, run_uhf, run_rohf and run_qmmm return a result whether or not the SCF converged. A non-converged result is not an error; it is a result with converged = False, and its energy is a plausible, wrong number. run_dft, run_ksdft and the correlated drivers (MP2, CC, RPA, GW, TDDFT) behave differently: they raise if their reference SCF does not converge.

converged = True means the SCF reached a stationary point. It does not mean the lowest one. The O2 triplet above with sto-3g instead of cc-pvdz shows this. run_uhf with the default MINAO guess converges to −147.63397 Ha, which is a saddle of the orbital Hessian 1.33 mHa above the UHF minimum at −147.635296 Ha (PySCF's default guess lands on the same saddle). Without stability_descent nothing in the result flags it. run_uhf(o2, bs, stability_descent=True) (CLI: [scf] stability_descent = true on kind = "uhf") follows the downhill eigenvector and reaches the minimum. guess="hcore" converges (converged=True) to a much higher stationary point, −147.3789 Ha, which lies above ferric's own ROHF (−147.63219 Ha). A UHF energy above the ROHF energy for the same molecule cannot be a ground state, so comparing the two is a cheap check for open-shell work.

Correlation

Correlated drivers run their own reference SCF internally, so they take the molecule and basis rather than an SCF result. They also take an explicit auxiliary (RI) basis. There is no automatic choice.

aux = ferric.BasisSet.bundled("cc-pvdz-ri")

rhf = ferric.run_rhf(water, bs_dz)
mp2 = ferric.run_rimp2(water, bs_dz, aux)
cc  = ferric.run_ccsd_t(water, bs_dz, aux)

print(f"RHF      {rhf.energy:.10f}")
print(f"RI-MP2   {mp2.total_energy:.10f}  (corr {mp2.mp2_corr:.10f})")
print(f"CCSD(T)  {rhf.energy + cc.correlation_energy + cc.t_correction:.10f}  total")
print(f"         {cc.correlation_energy:.10f}  CCSD correlation")
print(f"         {cc.t_correction:.10f}  (T)")
closed-shell CCSD converged in 10 iterations. E_corr = -0.2135061893
RHF      -76.0267679974
RI-MP2   -76.2308014541  (corr -0.2040334567)
CCSD(T)  -76.2433412449  total
         -0.2135061893  CCSD correlation
         -0.0030670582  (T)

The first line is progress output that the CCSD solver prints to stdout.

CcResult holds only correlation_energy and t_correction (which is None for run_ccd and run_ccsd). It carries no reference energy, so the total above adds run_rhf(...).energy by hand. The MP2-family results all carry a total_energy, and most also carry the reference energy.

Other members of the family use the same call shape:

att   = ferric.run_attenuated_rimp2(water, bs_dz, aux, omega=0.420)  # Å⁻¹
scs   = ferric.run_scs_mp2(water, bs_dz, aux)
sos   = ferric.run_laplace_sos_mp2(water, bs_dz, aux)
oo    = ferric.run_oo_rimp2(water, bs_dz, aux)
mp3   = ferric.run_mp3(water, bs_dz, aux)

frozen_core= is accepted by every correlated driver. run_terfc_rimp2 has no CLI method.kind of its own (the CLI reaches it through att-rimp2 with att_operator = "terfc"); the reference table below marks the CLI kind of every driver. run_rimp2, run_drpa and run_linlccd compute their method exactly unless local="amplitude-threshold" and eps= are passed (see Exact and local correlation).

Response and excited states

rpa   = ferric.run_pdep_rpa(water, bs_dz, aux)            # RPA correlation
gw    = ferric.run_gw(water, bs_dz, aux)                  # G0W0@HF by default
tddft = ferric.run_tddft(water, bs_dz, aux, n_roots=3, method="tda")

print(rpa.total_energy, rpa.eigensolver_converged)
print(gw.mo_indices, gw.eps_qp)          # quasiparticle energies, Hartree
print(tddft.excitation_energies)         # Hartree

run_gw runs method="g0w0" on an HF reference unless you pass xc= (for example xc="pbe"); by default it corrects HOMO−2 through LUMO+2. Check gw.outer_converged and gw.qp_converged before using the numbers. run_u_gw is the open-shell version.

run_tddft is closed-shell only. With functional=... it includes the (ia|f_xc|jb) exchange-correlation kernel; meta-GGA, VV10 and range-separated functionals are refused because their kernel is not built. With no functional, it is CIS (method="tda") or TDHF (method="casida"). Both methods match PySCF TDA/TDDFT to 1e-3 eV on the systems listed in Capabilities and validation.

Properties and charges

The property functions take the molecule, the basis and a converged RhfResult or DftResult. They work on closed-shell results.

import numpy as np

rhf = ferric.run_rhf(water, bs)                      # water / STO-3G

q_lowdin = ferric.lowdin_charges(water, bs, rhf)
q_resp   = ferric.resp_charges(water, bs, rhf)
print(np.round(q_lowdin, 4), np.round(q_resp, 4))

# ESP 3 Bohr above each atom. Points are an (N, 3) array in Bohr.
pts = np.array(water.coords_bohr()) + np.array([0.0, 0.0, 3.0])
print(np.round(ferric.esp_at_points(water, bs, rhf, pts), 6))
[-0.2525  0.1263  0.1263] [-0.6176  0.3088  0.3088]
[-0.064684 -0.067154 -0.067154]

The charge family is mulliken_charges, lowdin_charges, hirshfeld_charges, chelpg_charges and resp_charges, all returning one charge per atom in units of e. hirshfeld_charges builds its proatoms from free-atom SCF densities in the molecule's basis, as the CLI does; proatom="slater" selects the single-exponential Slater proatom instead, which is 0.23–0.72 e away on H2O, CO and CH3OH. resp_charges is a single-stage restrained fit, not the multi-stage, multi-conformer RESP procedure. esp_at_atoms gives the potential at each nucleus. hirshfeld_polarizability returns per-atom 3×3 polarizability tensors (Bohr³) and needs an RI basis.

For NPZ export of ML-ready features (MO coefficients, PDEP eigenvectors, ESP, charges, polarizabilities, C6 coefficients) in one run, use the CLI's [rpa] export_npz section; see Input file (TOML).

What comes back

Results are Python objects with plain attributes for scalars and methods for arrays:

D = rhf.density()             # numpy.ndarray, (n_bf, n_bf), AO basis
e = rhf.orbital_energies()    # numpy.ndarray, ascending, Hartree
C = rhf.mo_coefficients()     # numpy.ndarray, (n_bf, n_mo)

Matrices and tensors come back as numpy.ndarray. Per-atom lists (charges, ESP values) come back as Python lists; wrap them in np.asarray if you need arrays. run_drpa, run_linlccd and tune_omega return plain dicts, and run_drpa_scan returns a list of them.

AO-basis matrices follow libint2's basis-function conventions, which are not PySCF's. MEASURED on CO/cc-pVDZ: total and orbital energies agree with PySCF to 3e-12 and 8e-9 Ha, and the two AO density matrices differ element by element by up to 1.5. Compare invariant quantities, not raw AO matrices.

Memory, threads and MPI

Most drivers accept memory_budget_gb (GiB). It sets the same per-allocation limits as the CLI's [memory] budget_gb: an allocation that does not fit is spilled to disk, recomputed on demand (the DFT grid AO cache) or refused with an error naming it (for example run_rimp2 and run_ccsd). Unlike the CLI, Python installs no shared ledger, so each check compares its own allocation with the whole budget rather than with what other live allocations have left. Two checks instead subtract the process's current resident memory (RSS) first and allow 90% of the remainder: the KS-DFT decision to store or recompute the grid AO cache, and the UKS Newton/TRAH fxc kernel's second grid cache. Because RSS includes everything already resident, those two see less than the full budget. It is not a cap on total process memory; see Sharp bits.

mp2 = ferric.run_rimp2(water, bs_dz, aux, memory_budget_gb=8.0)

import ferric pins OpenBLAS to one thread unless OPENBLAS_NUM_THREADS is already set. ferric parallelizes with rayon instead. run_rhf, run_uhf, run_rohf, run_dft, run_rimp2, run_ccsd, run_ccsd_t, run_gw and run_tddft release the GIL, so independent jobs submitted from a ThreadPoolExecutor run in parallel. The other drivers hold it. For throughput across many molecules, prefer many single-threaded processes.

Do not run a Python script under mpirun. The bindings expose no rank or world-size accessor, so every rank runs the whole script. Distributed-memory runs go through the CLI built from source with MPI; see Installation.

Full reference

The module registers 61 public functions and 39 classes. That count excludes _cli_main, the entry point behind the ferric console command. It also exports two constants: DEFAULT_TEMPERATURE_K (298.15) and BOLTZMANN_HARTREE_PER_K. The list below was taken from the registration block of #[pymodule] fn ferric in crates/ferric-python/src/lib.rs, and each purpose line is condensed from that item's doc comment, or from its code where it has none. help(ferric.<name>) shows the full docstring and signature.

"CLI" gives the matching method.kind, task or TOML section, or "—" when the capability is Python-only. How well each one is validated is on Capabilities and validation.

Molecules and basis sets

NamePurposeCLI
MoleculeGeometry, charge and multiplicity. from_xyz, from_xyz_string, coords, coords_bohr, symbols, atomic_numbers, is_ghost, natoms, nelec, nuclear_repulsion, to_xyz_string.[molecule]
BasisSetA Gaussian basis set, orbital or auxiliary. BasisSet.bundled(name).[basis]

SCF and DFT

NamePurposeCLI
run_rhfClosed-shell RHF with the full SCF knob set, point charges, field and IEF-PCM solvent.rhf
run_uhfUnrestricted HF; α/β counts come from the molecule's charge and multiplicity.uhf
run_rohfRestricted open-shell HF (Guest–Saunders coupling); returns a UhfResult.rohf
run_dftClosed-shell Kohn–Sham DFT (LDA/GGA/hybrid/RSH/meta-GGA by name), optional dispersion (dispersion="d3bj" or "mbd", added to the energy and, with with_gradient=True, to the gradient) and analytic gradient.ksdft
run_ksdftAlias of run_dft.ksdft
d3bj_energyGrimme D3(BJ) dispersion energy for a molecule and functional, in Hartree.[dft] dispersion
mbd_rsscs_energyMBD@rsSCS dispersion energy (Hartree) from per-atom Hirshfeld volume ratios and β (or a functional with a published β); returns MbdRsscsResult with the screened α₀, C6, R_vdW and ω.—
tune_omegaIP-based (Baer/Kronik) tuning of an RSH functional's ω (Bohr⁻¹); closed-shell neutral plus doublet cation.—
dft_grid_point_countNumber of points in the main KS grid run_dft would build for the molecule with the same grid_* kwargs, without running an SCF (shows what pruning saves).—
RhfResultResult of run_rhf: energy, converged, iterations, density(), orbital_energies(), mo_coefficients().
UhfResultResult of run_uhf/run_rohf: α and β densities and orbital energies.
DftResultResult of run_dft: total_energy (= e_scf + e_dispersion), e_scf, e_dispersion (None when not requested), dispersion_model ("D3(BJ)", "MBD@rsSCS" or None), volume_ratios (MBD@rsSCS only), converged, exit_reason(), density(), gradient().

Constrained DFT

NamePurposeCLI
run_cdftConstrained UHF, or UKS when functional names a libxc functional other than "HF" (None and "HF", any case, give UHF): minimize the energy subject to fragment population constraints (Wu–Van Voorhis nested λ loop). Raises if the λ loop does not converge.—
CdftConstraintOne fragment constraint: atoms (0-based), target (a Becke electron population, not a net charge), kind = "charge" (Nα + Nβ) or "spin" (Nα − Nβ).—
cdft_couplingWu–Van Voorhis coupling H_ab between two converged single-"charge"-constraint states solved with the same geometry, basis, occupations and Hamiltonian.—
CdftResultenergy (without the constraint term), converged, scf_converged, lambdas, populations, targets, density_alpha(), density_beta(), weight_matrix(i).
CdftCouplingResulth_ab (sign is a phase convention), s_ab, e_a, e_b.

See Constrained DFT for a worked example.

Geometry, vibrations and reaction paths

NamePurposeCLI
run_optimizeRHF geometry optimization (basis by name).task = "optimize"
run_frequenciesHarmonic frequencies by finite differences of the analytic gradient; RHF/UHF/ROHF or their KS variants.task = "frequencies"
run_saddleFirst-order saddle-point (transition-state) search by P-RFO; closed-shell.—
run_ircIntrinsic reaction coordinate in both directions from a saddle; closed-shell.—
OptimizeResultenergy, converged, steps, energy_trace, mol().
FrequencyResultFrequencies in cm⁻¹ (negative = imaginary), normal modes, asymmetry diagnostic.
SaddleResultOutcome of a P-RFO search: geometry (Å), n_imaginary, imaginary_mode, is_transition_state().
IrcResultBoth directions of an IRC, plus the saddle they came from.
IrcBranchOne direction of an IRC walk.

QM/MM

NamePurposeCLI
QmmmSystemA QM/MM partition with link atoms and boundary-charge schemes (coordinates in Å).[qmmm]
MmTopologyExplicit-parameter AMBER-form MM force field; assigns no parameters itself.—
run_qmmmEmbedded SCF energy plus QM gradient, MM forces and full gradient.[qmmm] (energy)
run_optimize_qmmmOptimize a QmmmSystem; QM atoms always move, MM atoms per move_mm.—
QmmmResultenergy, qm_gradient(), mm_forces() (forces, not gradients), full_gradient().
QmmmOptimizeResultThe relaxed partition and its energy trajectory.

See QM/MM for a worked example.

MP2 family

NamePurposeCLI
run_rimp2RI-MP2 on an RHF reference; UHF + unrestricted RI-MP2 when multiplicity > 1 (reference says which). Exact by default; local=/eps= for the local approximation.rimp2
run_oo_rimp2Orbital-optimized RI-MP2 (level-shifted Newton + DIIS + Cayley rotation). Closed shell only; open-shell OO-RI-MP2 is CLI-only.oo-rimp2
run_mp3MP3 on an RHF reference, with RI integrals.mp3
run_attenuated_rimp2RI-MP2 with the erfc-attenuated operator; ω in Å⁻¹, default 0.420.att-rimp2
run_terfc_rimp2RI-MP2 with the exact tempered-erfc operator at one cutoff r0 (Å); needs the terfc tables.—
run_scs_mp2Spin-component-scaled MP2 (defaults c_OS = 6/5, c_SS = 1/3).scs-mp2
run_scs_mp2_2terfcDual-attenuated SCS-MP2(2terfc); needs the terfc tables.scs-mp2-2terfc
run_mp2_vMP2-V: attenuated MP2 plus damped VV10 nonlocal correlation.mp2-v
run_double_hybridB2PLYP or DSD-PBEP86 double hybrid.b2plyp, dsd-pbep86
run_laplace_mp2Laplace-transform RI-MP2 (default 7 quadrature points).laplace-mp2
run_laplace_sos_mp2Laplace-transform SOS-MP2, E = c_os · E_OS; MO, AO or AO-sparse formulations.laplace-sos-mp2
RiMp2ResultResult of run_rimp2 and run_terfc_rimp2: total_energy, rhf_energy (the reference SCF energy, RHF or UHF), mp2_corr, reference, and local (None for the exact method, else the local model dict).
OoRiMp2ResultResult of run_oo_rimp2, with converged and grad_norm.
Mp3Resulte_hf, e_mp2, e_mp3, e_corr, e_total.
AttenuatedMp2ResultAttenuated MP2 total, correlation and spin components.
ScsMp2ResultResult of run_scs_mp2/run_scs_mp2_2terfc, with e_os/e_ss.
Mp2VResultMP2-V total, attenuated MP2 part and VV10 part.
LaplaceMp2ResultLaplace MP2 total, correlation and spin components.
SosMp2ResultScaled and unscaled OS energy, c_os, n_quad and formulation echoed back.
DoubleHybridResultResult of run_double_hybrid: total_energy, e_ks, e_corr_scaled, e_os, e_ss, c_os, c_ss.

Exact and local correlation

run_rimp2, run_drpa and run_linlccd name a method and compute it exactly by default. The local approximation is asked for with keywords that mirror the CLI's [local] section, under the same rules, raised as ValueError before any SCF:

  • local= is None (exact; "none" is the same) or "amplitude-threshold"; any other value is an error.
  • eps= is required with local="amplitude-threshold": the threshold is part of the model and has no default. eps=0 reproduces the exact method. eps= without local= is an error, not ignored.
  • compute_reference=True (local only) also computes the exact method and reports it: local["e_corr_canonical_ri"] (MP2), e_corr_plasmon_canonical (dRPA), local["e_corr_exact"] (LinLCCD). Off, the key is present and None. It is a full exact calculation, so it removes any cost saving.
  • run_rimp2(integral_direct=True, ...) selects the integral-direct local MP2, with its locality maps aux_radius, virt_radius (Bohr), ao_tail, schwarz_skip, batch_merge, gate_cal and virt_schwarz_kappa (the CLI names and defaults). They are errors without integral_direct=True. kappa is an error on the local MP2.

Every result says which model it is: RiMp2Result.local and the dicts' "local" key are None for the exact method, else a dict with scheme, eps, keep_fraction and integral_direct plus solver counters. All local paths are closed shell.

NamePurposeCLI
run_drpadRPA@HF by the drCCD Riccati solve. Exact by default (equals the plasmon formula); MemoryError before the SCF when the exact solve cannot fit (use run_pdep_rpa(..., trunc_thresh=0)). diis= (default 8); eps_rtol_factor= (local only).drpa
run_drpa_scanThe local dRPA over a list of eps values, sharing one SCF and localization; each dict carries "local".drpa + [local] eps_sweep
run_linlccdLinearized ladder CCD, variant = "hh" (default), "drivers-only", "full". Exact by default.linlccd

Coupled cluster

All use RI integrals from the auxbasis argument and a closed-shell RHF reference.

NamePurposeCLI
run_ccdCCD correlation energy.—
run_ccsdSpin-adapted closed-shell CCSD.ccsd
run_ccsd_tCCSD plus the spin-adapted (T) correction.—
CcResultcorrelation_energy and t_correction (None without triples). No reference energy.

RPA, GW and excited states

NamePurposeCLI
run_pdep_rpaDirect RPA correlation energy by PDEP (projective dielectric eigenpotentials); accepts point charges, field and solvent. Closed shell only; open-shell U-PDEP-RPA is CLI-only.pdep-rpa
run_rs_mp2_rpaRange-separated SR-MP2 + LR-RPA (formulation = "delta-lr" or "coupled-rings"; ω in Å⁻¹).rs-mp2-rpa
run_gwClosed-shell G0W0 / COHSEX / evGW0 / evGW on an RHF or RKS reference.gw
run_u_gwOpen-shell GW variants on a UHF/UKS or ROHF reference.gw with multiplicity > 1
run_bse_tdaBSE-TDA singlet excitation energies on a closed-shell RHF reference.bse-tda
run_tdhf_static_polarizabilityRPAx@KS static (ω = 0) polarizability on a closed-shell KS reference.tdhf-static-polarizability
run_tddftTDA or Casida excitations on a closed-shell HF (CIS/TDHF) or KS reference, with the f_xc kernel.tda, tddft
PdepRpaResulttotal_energy, e_rpa, eigensolver_converged, eigenvalues and quadrature grid.
RsMp2RpaResultSR-MP2, LR-MP2 and dRPA pieces; which fields are set depends on formulation.
GwResulteps_qp, eps_mf, sigma_x, sigma_c, z_factor, outer_converged, qp_converged.
UGwResultα and β versions of the GwResult fields.
BseResultExcitation energies and oscillator strengths.
TdhfStaticPolarizabilityResultPolarizability tensor and its isotropic value iso.
TddftResultexcitation_energies, oscillator_strengths, method.

Properties and charges

Each takes (mol, basis_set, result) with a converged closed-shell RhfResult or DftResult; esp_at_points also takes the points and hirshfeld_polarizability an RI basis.

NamePurposeCLI
esp_at_atomsElectrostatic potential at each nucleus, in atomic units.[rpa] compute_esp
esp_at_pointsElectrostatic potential at arbitrary points given in Bohr.[rpa] compute_esp_surface (vdW-surface points only)
mulliken_chargesMulliken population charges.[rpa] compute_mulliken_charges
lowdin_chargesLöwdin (symmetric-orthogonalization) charges.[rpa] compute_lowdin_charges
hirshfeld_chargesHirshfeld charges. The default proatom="scf" uses free-atom SCF densities in the molecule's basis, solved with the result's own SCF settings, as the CLI does; proatom="slater" uses a single-exponential Slater proatom.[rpa] compute_hirshfeld_charges
chelpg_chargesCHELPG charges fitted to the ESP on a grid.[rpa] compute_chelpg_charges
resp_chargesSingle-stage RESP (restrained ESP-fit) charges.[rpa] compute_resp_charges
hirshfeld_polarizabilityPer-atom Hirshfeld-partitioned static polarizability tensors (Bohr³) from PDEP-RPA.—
orbital_momentsPer-orbital centroids and spatial spreads (Bohr) of the restricted MOs.—
density_second_moment3×3 second-moment tensor of the electron density (Bohr²).—

The [rpa] compute_* keys write into the CLI's NPZ export.

Integrals and orbitals (prototyping)

These take omega/r0 in raw Bohr units, unlike the run_* drivers.

NamePurposeCLI
compute_eri3Raw 3-centre Coulomb integrals (P|μν), shape (naux, n_bf, n_bf).—
compute_eri3_moMO-basis 3-centre integrals (P|pq) for any two coefficient matrices, built blockwise under a memory budget.—
compute_metric_2c2-centre metric (P|w|Q) over the auxiliary basis, Coulomb by default.—
shell_infoShell centres (Bohr), first-function offsets and sizes, for building fitting domains.—
boys_localizeFoster–Boys localization of given orbitals; returns a BoysResult.—
BoysResultResult of boys_localize: c_loc(), centers(), converged, iterations.

Conformer ensembles

NamePurposeCLI
ConformerEnsembleConformers of one species sharing atom order, composition, charge and multiplicity.—
BoltzmannWeightsBoltzmann populations of an ensemble at one temperature.—
EnsembleDiagnosticsPopulation-structure readout: effective number of conformers, dominance verdict.—
WeightedStatsA weighted mean with its spread (mean, std_dev, min, max).—
boltzmann_weightsBoltzmann weights from a list of energies (Hartree) at a temperature (default 298.15 K).—
weighted_statsWeighted mean and standard deviation of a scalar property.—
weighted_stats_vectorThe same, component-wise, for a vector property.—
weighted_stats_tensorThe same, element-wise, for a rank-2 tensor property.—

Examples

This page indexes every input file in examples/. There are 70 TOML files and no Python scripts. Run one from the repository root:

OPENBLAS_NUM_THREADS=1 cargo run --release --bin ferric -- examples/water-rhf.toml

What CI checks

CI parses every shipped example, but runs only the three listed below. The test all_shipped_examples_parse (in crates/ferric-cli/src/config.rs) loads every examples/*.toml through the strict parser. It also checks that each file's [molecule] xyz path exists. It does not execute the calculation or compare any energy.

Three examples are also executed by integration tests in crates/ferric-cli/tests/:

ExampleTestWhat the test asserts
water-rhf.tomlepistemic_warning.rs, verbose_trace.rs, qmmm_reports_the_pqr_as_its_geometry.rsThe run completes and prints no [warning] grade line.
water-tdhf-static-alpha.tomlepistemic_warning.rsThe Smoke-grade warning goes to stderr and not to stdout.
water-qmmm.tomlqmmm_reports_the_pqr_as_its_geometry.rsThe embedded energy -74.9653197421 appears in the output.

Every other number in the "Reference in header" column below is copied verbatim from that file's comment block. Nothing in CI re-checks it. Where the header cites a Rust test as the real reference, that test is the check, not the example.

Examples that need extra setup

  • water-mp2v.toml, water-scs-mp2-2terfc.toml, water-attmp2-terfc.toml and any rs-mp2-rpa run with attenuator = "terf" need the tempered-erfc interpolation tables: point FERRIC_TERF_TABLE_DIR at them. Without it these runs stop with an error.
  • benzene-dfb3lyp-mpi.toml is an ordinary input; its header gives the --features mpi build and the mpirun launch line.

SCF and DFT

See SCF and DFT.

FileSystem / basiskind / taskReference in headerNotes
water-rhf.tomlH2O / STO-3Grhf / energy—water-qmmm.toml's header gives this geometry's energy as −74.9631468000. The file is commented to demonstrate screening.
benzene-rhf.tomlbenzene / cc-pVDZrhf—Exact 4-index J/K.
benzene-rhf-dfj.tomlbenzene / cc-pVDZrhf—RI-J only (cc-pvdz-ri).
benzene-rhf-rijk.tomlbenzene / cc-pVDZrhf—RI-JK (def2-universal-jkfit).
benzene-rhf-def2.tomlbenzene / def2-SVPrhf—
benzene-rhf-def2-rijk.tomlbenzene / def2-SVPrhf—RI-JK.
decane-rhf.tomldecane / STO-3Grhf—k_builder = "link".
water-rhf-cosx.tomlH2O / cc-pVDZrhf"E(COSX) − E(direct) is reported in crates/ferric-scf/tests/cosx_scf.rs"COSX exchange.
h_uhf.tomlH atom / STO-3G, doubletuhf—
h2_opt.tomlstretched H2 / STO-3Grhf / optimize—
water-frequencies.tomlH2O / STO-3Grhf / frequencies—FD Hessian. Check Hessian asymmetry.
benzene-dfb3lyp.tomlbenzene / def2-SVPksdft B3LYP—RI-JK is on automatically for ksdft.
benzene-dfb3lyp-mpi.tomlbenzene / cc-pVDZksdft B3LYP—Run under mpirun with the MPI build (see its header).
water-wb97xv.tomlH2O / cc-pVDZksdft wB97X-V—
water-pbe-d3bj.tomlH2O / cc-pVDZksdft PBE + D3(BJ)—Prints E(KS-DFT) and E(D3BJ) separately.
water-pbe-mbd.tomlH2O / cc-pVDZksdft PBE + MBD@rsSCS—Prints E(KS-DFT) and E(MBD@rsSCS) separately.
water-pbe-pruned-grid.tomlH2O / cc-pVDZksdft PBE / energy"removes ~23% of the grid points" (at 75×110)Validated in crates/ferric-dft/tests/grid_prune_live_scf.rs.
h2-lda-opt.tomlH2 / STO-3Gksdft LDA / optimize—
water-qmmm.tomlH2O + Na⁺ (PQR) / STO-3Grhf + [qmmm]"vacuum −74.9629466809, embedded −74.9653197421, i.e. −1.489 kcal/mol from the ion at 4 A. Verified against ferric.run_rhf(point_charges=...) to all 10 digits."The vacuum number is at the PQR geometry, not the xyz. The embedded number is asserted by a test.
water-pcm.tomlH2O / STO-3Grhf + [pcm]—IEF-PCM water (solvent = "water", ε = 78.4).
water-rhf-smeared-charge.tomlH2O / STO-3Grhf + [external_potential]—One Gaussian-smeared charge (width, Bohr) and one point charge.
o2-uhf-stability-descent.tomlO2 triplet / STO-3Guhf"the descent follows the downhill eigenvector to the UHF minimum (-147.63530 Ha)"[scf] stability_descent = true.

MP2 family

See The MP2 family.

FileSystem / basiskindReference in headerNotes
water-rimp2.tomlH2O / cc-pVDZrimp2—All-electron.
water-rimp2-frozen-core.tomlH2O / cc-pVDZrimp2Expected log line: "[ferric] frozen core: 1 orbital(s) frozen from [mp2] frozen_core = "auto""
water-rimp2-local.tomlH2O / 6-31Grimp2 + [local]—Amplitude-threshold local MP2, eps = 1e-4. Sets reference = true, so it also computes the exact RI-MP2 and prints the local error against it (off by default).
alkane8-rimp2-local-direct.tomloctane / 6-31Grimp2 + [local]—Integral-direct local MP2 (integral_direct = true), every locality map at its default.
water-drpa.tomlH2O / 6-31Gdrpa—Exact dRPA (no [local]).
water-drpa-local.tomlH2O / 6-31Gdrpa + [local]—Amplitude-threshold dRPA, eps = 1e-4, reference = true (prints the error against the canonical plasmon dRPA). A comment shows the eps_sweep form.
water-mp3.tomlH2O / cc-pVDZmp3—
water-oo-rimp2.tomlH2O / cc-pVDZoo-rimp2—Proven (narrow) grade.
water-attmp2.tomlH2O / aug-cc-pVDZatt-rimp2—ω = 0.420 Å⁻¹.
water-attmp2-terfc.tomlH2O / aug-cc-pVDZatt-rimp2—att_operator = "terfc", att_r0 = 1.05 Å. Needs the terf tables.
water-scs-mp2.tomlH2O / cc-pVDZscs-mp2—Grimme coefficients (defaults).
water-scs-mp2-2terfc.tomlH2O / cc-pVDZscs-mp2-2terfc—Thesis defaults r0 = 0.75/1.05 Å. Needs the terf tables.
water-laplace-rimp2.tomlH2O / cc-pVDZlaplace-mp2—
water-laplace-sos-mp2.tomlH2O / cc-pVDZlaplace-sos-mp2—
water-mp2v.tomlH2O / aug-cc-pVDZmp2-v—The header warns that aDZ is outside the fitted basis. Needs the terf tables.
water-rs-mp2-rpa.tomlH2O / aug-cc-pVDZrs-mp2-rpa—Smoke grade.

Coupled cluster and double hybrids

See Coupled cluster and RPA and GW § double hybrids.

FileSystem / basiskindReference in headerNotes
water-ccsd.tomlH2O / cc-pVDZccsd"PySCF CCSD run on the same density-fitted integrals gives E_corr = -0.2135061893 Ha"All electrons, aux cc-pvdz-ri.
water-linlccd.tomlH2O / 6-31Glinlccd—Exact LinLCCD(hh).
water-linlccd-local.tomlH2O / 6-31Glinlccd + [local]—Amplitude-threshold LinLCCD(hh), eps = 1e-4.
water-ccd.tomlH2O / STO-3Gccd—
water-ccsd-t.tomlH2O / STO-3Gccsd(t)—Prints the CCSD correlation energy, the (T) correction and the total.
water-wb97xlv.tomlH2O / 6-31Gwb97x-l-v—λ = 0.6, ω = 0.1 Bohr⁻¹ (published values). Proven (narrow) grade.
water-b2plyp.tomlH2O / cc-pVDZb2plyp—Spike grade. Aux cc-pvdz-rifit (an alias of cc-pvdz-ri).

RPA, C6 and properties

See RPA and GW.

FileSystem / basiskindReference in headerNotes
water-pdep-rpa.tomlH2O / cc-pVDZpdep-rpa—Writes eigenpotential cube files.
h2o-pdep-rpa-props.tomlH2O / cc-pVDZpdep-rpa—NPZ export.
benzene-pdep-rpa.tomlbenzene / def2-SVPpdep-rpa—RI-JK SCF.
benzene-pdep-rpa-export.tomlbenzene / cc-pVDZpdep-rpa—Exports cube files.
benzene-rijk-pdep-rpa.tomlbenzene / cc-pVDZpdep-rpa—Full rank (trunc_thresh = 0).
water-c6-pdep.tomlH2O / aug-cc-pVTZpdep-rpa, c6_source = "pdep""DOSD molecular reference (Meath/Toulouse): C6(H2O–H2O) = 45.3 a.u."Compare the printed "molecular C6" line against it, not the sum of the NPZ c6_iso entries. Writes to /tmp.
argon-c6-rpa-pbe.tomlAr / aug-cc-pVTZpdep-rpa @PBE"C6(Ar-Ar) = 56.4 a.u. vs DOSD 64.3 (-12%); the full He/Ne/Ar sweep gives mean |err| 8.9% at RPA@PBE — vs 39% at RPA@HF"Writes to /tmp.
water-tdhf-static-alpha.tomlH2O / cc-pVDZtdhf-static-polarizability @PBE"alpha_iso = 5.20 a.u. … against the DOSD reference 9.64 a.u.: 46% LOW"Sets [gw] scissor = 0.36 Ha; at scissor = 0 the run is refused (negative α diagonal). Static α only. Executed by a test (for the warning only).

GW and BSE

FileSystem / basiskindReference in headerNotes
water-g0w0-pbe.tomlH2O / cc-pVDZgw G0W0@PBE"HOMO IP should match … PySCF gw_ac reference (11.1714 eV) to <0.1 eV" (crates/ferric-gw/tests/g0w0_pbe_h2o.rs)
oh-ugw.tomlOH doublet / cc-pVDZgw U-G0W0@UHF"alpha-HOMO IP in the ~13-14 eV window, bracketing experiment 13.02 eV" (crates/ferric-gw/tests/oh_u_g0w0.rs)
oh-ugw-rohf.tomlOH doublet / cc-pVDZgw U-G0W0@ROHF—[gw] reference = "rohf".
water-bse-tda.tomlH2O / cc-pVDZbse-tda"Measured via this exact TOML (2026-07-18): 8.4572 eV", against a PySCF-integral cross-check of 8.46 eV and a sanity window of [5, 12] eV
water-augccpvdz-bse-tda.tomlH2O / aug-cc-pVDZbse-tdaLiterature: "aug-cc-pVDZ CCSDT Delta_Evert = 9.279 eV, f = 0.058; CBS TBE = 7.71+-0.02 eV, f = 0.052+-0.001"No ferric number is recorded.
h2co-bse-tda.tomlH2CO / cc-pVDZbse-tda—Pilot run. The lowest state is dark (f ≈ 0).
c2h4-bse-tda.tomlC2H4 / cc-pVDZbse-tda—Pilot run.
formaldehyde-bse-tda-augdz.tomlH2CO (QUESTDB geometry) / aug-cc-pVDZbse-tdaLiterature: "lowest singlet 1^1A2 (n->pi*, symmetry-forbidden) at 3.966 eV (QUESTDB TBE…)"The template for the Thiel-set files below.
acetaldehyde-bse-tda-augdz.toml, butadiene-bse-tda-augdz.toml, cyclopropene-bse-tda-augdz.toml, ethylene-bse-tda-augdz.toml, furan-bse-tda-augdz.toml, glyoxal-bse-tda-augdz.toml, pyrazine-bse-tda-augdz.toml, pyridine-bse-tda-augdz.toml, pyrimidine-bse-tda-augdz.tomlQUESTDB Thiel-set molecules / aug-cc-pVDZbse-tda—Same setup as the formaldehyde file. The reference values are in testdata/reference/thiel_set_subset.json.

TDDFT

FileSystem / basiskindReference in headerNotes
water-tda.tomlH2O / cc-pVDZtda (CIS, 5 roots)—Default aux cc-pvdz-rifit (an alias of cc-pvdz-ri).
water-tddft-pbe.tomlH2O / cc-pVDZtddft @PBE (5 roots)—Includes the f_xc kernel.

See also Capabilities and validation for the grade of each kind and its evidence, and Input reference for every key.

Run logs

Every ferric CLI run writes a machine-readable JSON Lines log, without being asked.

The format is ferric's own. It is not QCSchema, and there is no QCSchema exporter. Each record's schema field (currently 1, on run_start) versions this format. It is not a QCSchema version.

$ ferric water-rhf.toml
[ferric] JSON run log: water-rhf.ferric.jsonl
...

Why it is on by default

A result whose run left no artifact can't be checked. Stdout captures get truncated, and a claim such as "173 iterations, converged" that survives only in a truncated capture can be repeated but never checked. The run log keeps the record on disk whether or not anyone captured stdout, which is why it is on by default.

Opt out explicitly if you need to:

[output]
json = false          # no log
# json = "runs/a.jsonl"  # or put it somewhere specific

or from the command line:

ferric --no-json input.toml
ferric --json runs/benzene.jsonl input.toml

The default path is the input file's name with .ferric.jsonl in place of its extension, written beside the input.

A log never fails a calculation. If the path cannot be opened or the disk fills, ferric warns once on stderr and the run continues.

Format

One JSON object per line, written and flushed as it happens — not buffered to the end of the run. A job that is OOM-killed, hits a wall-clock limit, or is Ctrl-C'd at iteration 90 of 100 leaves those 90 iterations on disk. Only the final line may be truncated; everything before it parses.

jq -c 'select(.record=="scf_iter") | {iter, energy, dp_rms}' water-rhf.ferric.jsonl

Every record carries record (its type), seq (a gap-free counter, so a missing record is detectable) and t (seconds since the run started).

run_start

Written before any expensive work, so it survives a job killed in the first iteration. Carries the ferric version, the git SHA the binary was built from (null if the build had no checkout; a -dirty suffix means uncommitted changes were compiled in), a UTC timestamp, the resolved config — including the memory budget actually used, not the key as written — and the molecule.

scf_iter

One per SCF iteration: iter, energy, de, dp_rms, dp_max, err_max, and grad_rms where the solver forms one. rung says which ladder rung the iteration belongs to. method is rhf/uhf/rohf or their KS counterparts rks/uks/roks.

These are the quantities the solver already computes for its convergence decision (see scf_converged) — the log reports them, it does not create them.

dp_rms and dp_max are "inf" on the first iteration of each SCF, before there is a previous density to compare against. JSON has no infinity, so non-finite values are written as the strings "inf", "-inf" and "NaN" rather than as null, which would make a NaN indistinguishable from a field that was simply not reported.

guess_scf_iter

Identical shape, but for the free-atom SCFs the SAD/MINAO initial guess runs per element. They are real work and can fail, so they are logged — under a different record type so that free oxygen's energy is never mistaken for the molecule's.

ladder_rung

One per convergence-ladder rung: rung, tricks (which accelerators it enables), iterations, exit, energy, converged — and max_iter.

That last field matters more than it looks. In the CLI, kind = "rhf" and kind = "ksdft" run through a convergence ladder, and unless you define your own [[scf.ladder]] rungs, the default ladder sets its own per-rung iteration caps:

kindrung 0rungs 1–4
rhf6060 / 60 / 80 / 100
ksdft[scf] max_iter60 / 60 / 80 / 100

So for rhf, [scf] max_iter does not set the per-rung cap. A run that reports "iterations = 100" is usually rung 4 using up its own budget, not a single hundred-iteration SCF. The cap and the count are recorded together so the log can't be misread that way.

run_end

The terminal record: energy, converged, exit, wall_s, cpu_s and peak_rss_bytes (the high-water mark, not the RSS at exit).

For a post-SCF method the energy is the SCF reference, not the method's total. extra.energy_is says which. Per-iteration records for MP2/RPA/CC/GW are not implemented yet.

Only task = "energy" writes run_end. task = "optimize" and task = "frequencies" currently write run_start and the SCF iteration records, but no run_end, ladder_rung or result. Read the final geometry and energy from stdout for those tasks. A missing run_end in such a log doesn't mean the run failed (MEASURED on examples/h2-lda-opt.toml, 2026-09-23).

result

The number a correlated run was launched to produce. It's written once, after the method finishes. Plain SCF runs (rhf, uhf, rohf, ksdft) don't write one, because for them run_end.energy is the answer.

fieldmeaning
kindthe method that produced it, e.g. "rimp2", "ccsd", "gw"
totalthe headline number a user would quote
componentsthe decomposition that makes total checkable — e_corr, e_os/e_ss, the reference energy, and whatever else the method defines

total and run_end.energy are different numbers for every post-SCF method. A kind="rimp2" run's run_end.energy is the RHF energy it was built on; its result.total is the RI-MP2 total. Reading the wrong one silently gives an uncorrelated answer.

components exists because a total alone cannot be reconciled against a reference implementation — you need the pieces to see where a disagreement comes from.

result_unlogged

A post-SCF method that isn't wired up to result yet writes this instead, naming the kind. The methods that do write result are rimp2, oo-rimp2, att-rimp2, laplace-mp2, laplace-sos-mp2, scs-mp2, scs-mp2-2terfc, mp3, ccsd, ccd, ccsd(t), linlccd, drpa, mp2-v and rs-mp2-rpa. For rimp2, drpa and linlccd the record's components carry "local": null for the exact method, else {"scheme", "eps", "keep_fraction", "integral_direct"}.

It exists because silence is ambiguous: a log with no result record could mean the method does not record one yet, or that the run died before producing one. Those demand opposite responses from whatever reads the log. This makes the first case explicit and leaves "the run failed" as the only remaining reading of a genuinely missing record.

Example

A water/STO-3G RHF run, abridged:

{"record":"run_start","schema":1,"seq":0,"t":0.002,"ferric_version":"0.1.0","git_sha":"396e0d61eded-dirty","timestamp":"2026-09-18T16:04:12Z","config":{"method":"rhf","basis":"sto-3g","max_iter":100,"density_conv":1e-7,"memory_budget_bytes":13018454425,"rayon_num_threads":12,"mpi_ranks":1},"molecule":{"formula":"H2O","n_atoms":3,"n_basis":7,"n_electrons":10,"charge":0,"multiplicity":1}}
{"record":"guess_scf_iter","seq":1,"t":0.014,"method":"uhf","iter":1,"energy":-0.4665818503784864,"dp_rms":"inf","dp_max":"inf","de":0.4665818503784864,"err_max":0.0,"grad_rms":null,"rung":0}
{"record":"scf_iter","seq":5,"t":0.017,"method":"rhf","iter":1,"energy":-74.68273456362847,"de":74.68273456362847,"dp_rms":"inf","dp_max":"inf","err_max":1.6088693243641559,"grad_rms":0.5469406324593924,"rung":0}
{"record":"scf_iter","seq":13,"t":0.019,"method":"rhf","iter":9,"energy":-74.9631468000391,"de":5.684341886080802e-14,"dp_rms":1.356320515468342e-10,"dp_max":4.726057323267696e-10,"err_max":3.5214886562329184e-14,"grad_rms":1.2777284834157483e-14,"rung":0}
{"record":"ladder_rung","seq":14,"t":0.019,"rung":0,"tricks":["diis"],"max_iter":60,"iterations":9,"exit":"Converged","energy":-74.9631468000391,"converged":true}
{"record":"run_end","seq":15,"t":0.019,"energy":-74.9631468000391,"converged":true,"exit":"Converged","wall_s":0.0189,"cpu_s":0.05,"peak_rss_bytes":128815104,"extra":{"method":"rhf","task":"energy","scf_iterations":9,"energy_is":"total"}}

Does it change the answer?

No, and that is a regression test, not a claim: SCF energies are bit-identical (f64::to_bits()) with the log on and off, asserted across RHF, RKS, UHF and ROHF in crates/ferric-scf/tests/runlog_bit_identity.rs. Logging is observation, never participation — every value written is one the solver had already computed for its own convergence decision, and nothing is ever read back.

Does it cost anything?

The per-record cost has not yet been measured on a quiet machine. Run scripts/bench-runlog-overhead.sh to measure it; the script explains why the emit path must be timed directly rather than by diffing whole-process wall times, and what to compare the result against.

API documentation

The crate-level API docs are generated by rustdoc and published alongside this book.

Browse the API documentation →

Generated by rustdoc and published alongside this book.

That link is only present when the most recent CI run on main succeeded. cargo doc does not link, but it does run build scripts, and ferric-integrals' build script compiles a C++ shim needing libint2.hpp — so the API docs are built by the CI workflow (which already builds and caches libint2) and handed to the docs workflow as an artifact. If CI was red, the book still publishes and this link 404s; generate the docs locally in that case.

Generating locally

cargo doc --workspace --no-deps --open

Drop --no-deps to include dependency documentation as well (much slower, and much larger).

If that fails with "Only one may be documented at once since they output to the same path", add --exclude ferric-python. The pyo3 crate's lib is deliberately named ferric — that is what makes Python's import ferric work — which collides with the ferric facade crate. Excluding it costs nothing: it is a cdylib, and its surface is documented in Python bindings.

Entry points

The most useful starting points, by crate:

CrateStart at
ferric_coreMolecule, BasisSet, Shell
ferric_scfsolve_rhf, solve_uhf, solve_rohf; constrained DFT in cdft_driver::solve_cdft_uhf and cdft_coupling::coupling_hab (Python: run_cdft, cdft_coupling)
ferric_mp2ri_mp2, oo_ri_mp2
ferric_ccccsd_closed_shell, ccsd_t_closed_shell
ferric_rpapdep_polarizability_static, RPA correlation drivers
ferric_gwrun_gw, run_evgw
ferric_dftKsXc, functional construction via libxc
ferric_tensorsthe einsum! macro

On reading the docs

The doc comments in this codebase carry more than signatures. Where a design decision was hard-won — a convergence hazard, a memory-accounting subtlety, a parallelization that would be unsafe — the reasoning is recorded at the point of use, including cases where an apparently obvious optimization was rejected and why.

Those notes are often the most useful part of the documentation for anyone modifying the code, and they are deliberately kept next to the code rather than in prose docs that drift.

Pharma use-case coverage

Every named use case, what runs it, what it costs, and the plot that answers it. Costs are MEASURED on this project; where a figure depends on basis or on warm-vs-cold, both are given, because quoting one hides a 3-30x spread.

Coverage

use caseentry pointcost (measured)plot
dockingdocking.vina_dock, tiers.tier1_dock31 s @ 57 atoms, 5.7 @ 21, 1.9 @ 9 (ex=4, 7LCJ); ~N^1.5pose_ensemble, funnel_survival
docking geom optactive_site.pose_relaxation77.8 s/step @ 71 atoms in a 6458-charge pocketoptimization_trace
minima with FFtiers.tier2_forcefield9 ms @ 21 atoms (2.2 ms @ 9 atoms, 8.2 @ 19, 21.6 @ 34)tier_comparison
minima with xtbtiers.tier3_gfn239 ms @ 21 atoms (0.152 s @ 9, 0.050 @ 19)tier_comparison
score with DFTtiers.tier4_dft2.6 s @ 9 atoms at the def2-svp DEFAULT (0.75 s at STO-3G)tier_comparison
transition stateferric.run_saddle2*(6N+1) + (n_steps+1) gradientsimaginary_mode
reaction path (IRC)ferric.run_irc~70 gradients/branchreaction_path
common substitutionspipeline.substitution7.6 ms warm / 7 proposals (248 ms first call)site_substituent_heatmap
toxicologytox.alerts, tox.assess3.7 ms screen; 54 ms offline assess, 1.6 s with the default include_web=Trueliability_profile
binding energy in siteactive_site.binding_energy137 s @ 71 atoms / 6458 charges (TWO SCFs + pdb2pqr)pocket_polarization
QM/MM setupferric.QmmmSystemfreeqmmm_partition
dispersion D3(BJ)run_dft(dispersion="d3bj")microseconds, energy and gradientfolded into the DFT energy

Where the campaign time actually goes

The whole funnel RUN end to end — 10 substitution candidates of benzoic acid through dock → FF → xtb → DFT against the 7LCJ pocket, keeping 6/4/2/1, zero failures, same survivor both times:

basistotaldockFFxtbDFT
STO-3G45.0 s72.7%0.1%0.3%27.0%
def2-svp (the tier4_dft DEFAULT)82.1 s39.8%0.1%0.1%60.0%

"Docking dominates, not DFT" holds only at STO-3G. At the default basis the ranking inverts and DFT is the majority of the run. The absolute docking cost is identical between the rows (32.7 s); it is DFT that moves, because tier4_dft defaults to def2-svp and that is 3.5x STO-3G.

So: quote the share WITH the basis, and decide where to optimize from the row that matches the basis you actually run.

What "has a plot" does and does not mean

The plot has to answer that use case's question. A transition-state search produces an imaginary MODE — a 3N vector — so imaginary_mode shows whether it displaces the reacting atoms, which is the second and non-optional half of verifying a saddle. One imaginary frequency is necessary, not sufficient: a methyl rotor gives one too.

A plot and a cost do not license a RANKING. The binding-energy row has both and still cannot order two analogues: every pose protocol tried is closed (RESULTS.md M4-M13), and the best available ddE noise is ~4.07 kcal/mol against substituent effects of 1-2. site_substituent_heatmap(noise_floor=...) greys out every cell inside that limit so a figure cannot imply otherwise.

Known gaps

  • AMBER prmtop — no reader; go through OpenMM.
  • Periodic boundary conditions — absent. solvate() gives a finite droplet with a vacuum boundary.
  • QM/MM dispersion — D3/D4/XDM/VV10 are QM-atom-pairwise, so dispersion between the QM region and MM charges is absent.
  • Pose-ensemble ranking — see above; this is a noise floor, not a missing feature.

References and citing

Citing ferric

If you publish a number computed with ferric, cite three things:

  1. The software. The repository carries a CITATION.cff, which GitHub turns into a "Cite this repository" entry:

    Goldey, M. ferric: a Rust-native quantum chemistry engine. https://github.com/mgoldey/ferric. Licensed MIT OR Apache-2.0.

    Give the commit hash you ran; the crates are at version 0.1.0 and there are no tagged releases yet.

  2. The libraries every calculation goes through.

    • libint2 (all Gaussian integrals): E. F. Valeev, Libint: a library for the evaluation of molecular integrals of many-body operators over Gaussian functions, https://github.com/evaleev/libint. Cite the version you built against (ferric builds on libint 2.7+).
    • libxc (every DFT functional): S. Lehtola, C. Steigemann, M. J. T. Oliveira & M. A. L. Marques, SoftwareX 7, 1 (2018), doi:10.1016/j.softx.2017.11.002. Only needed if you ran a DFT functional or a DFT reference.
  3. The method papers for what you ran, from the list below. Each method page also ends with a short Cite line naming the entries it relies on.

Methods implemented in ferric

Entries are limited to methods the code implements, and most are the references the source code itself cites.

SCF, convergence and integrals

  • Szabo & Ostlund, Modern Quantum Chemistry (Dover, 1996)
  • Pulay, Chem. Phys. Lett. 73, 393 (1980): DIIS
  • Kudin, Scuseria & Cancès, J. Chem. Phys. 116, 8255 (2002): EDIIS
  • Gilbert, Besley & Gill, J. Phys. Chem. A 112, 13164 (2008): maximum overlap method (MOM)
  • Ochsenfeld, White & Head-Gordon, J. Chem. Phys. 109, 1663 (1998): LinK exchange
  • Maurer, Lambrecht & Ochsenfeld, J. Chem. Phys. 136, 144107 (2012): QQR screening
  • Thompson & Ochsenfeld, J. Chem. Phys. 147, 144101 (2017): CSB and CSAM integral screening
  • White & Head-Gordon, J. Chem. Phys. 101, 6593 (1994): continuous fast multipole method
  • Neese, Wennmohs, Hansen & Becker, Chem. Phys. 356, 98 (2009): COSX seminumerical exchange
  • Izsák & Neese, J. Chem. Phys. 135, 144105 (2011): COSX overlap fitting
  • Weigend, Phys. Chem. Chem. Phys. 4, 4285 (2002): RI-JK (fully direct RI-HF) and its auxiliary sets
  • Dunlap, J. Mol. Struct. (THEOCHEM) 529, 37 (2000): robust density fitting

DFT, grids and dispersion corrections

  • Becke, J. Chem. Phys. 88, 2547 (1988): multicentre integration (Becke partitioning)
  • Treutler & Ahlrichs, J. Chem. Phys. 102, 346 (1995): radial grids (M4 mapping)
  • Lebedev & Laikov, Dokl. Math. 59, 477 (1999): angular grids
  • Perdew, Burke & Ernzerhof, Phys. Rev. Lett. 77, 3865 (1996): PBE
  • Becke, J. Chem. Phys. 98, 5648 (1993); Stephens, Devlin, Chabalowski & Frisch, J. Phys. Chem. 98, 11623 (1994): B3LYP
  • Vydrov & Van Voorhis, J. Chem. Phys. 133, 244103 (2010): VV10 nonlocal correlation
  • Mardirossian & Head-Gordon, Phys. Chem. Chem. Phys. 16, 9904 (2014): ωB97X-V
  • Sun, Ruzsinszky & Perdew, Phys. Rev. Lett. 115, 036402 (2015): SCAN
  • Furness, Kaplan, Ning, Perdew & Sun, J. Phys. Chem. Lett. 11, 8208 (2020): r2SCAN
  • Grimme, Antony, Ehrlich & Krieg, J. Chem. Phys. 132, 154104 (2010): DFT-D3
  • Grimme, Ehrlich & Goerigk, J. Comput. Chem. 32, 1456 (2011): Becke–Johnson damping for D3
  • Tkatchenko & Scheffler, Phys. Rev. Lett. 102, 073005 (2009): TS dispersion and free-atom reference data
  • Gould & Bučko, J. Chem. Theory Comput. 12, 3603 (2016): free-atom reference data for Z = 19–54 (all but Pd)
  • Jerabek, Schwerdtfeger & Nagle, Phys. Rev. A 98, 012508 (2018): free-atom Pd polarizability (26.14 a.u., closed-shell 4d¹⁰)
  • Gobre, PhD thesis, TU Berlin (2016), Table A.1: free-atom Pd C6 (157.5 a.u., the TS value used by libMBD) and the free-atom vdW radii R_vdW (Z = 1–54) used by MBD@rsSCS
  • Tkatchenko, DiStasio, Car & Scheffler, Phys. Rev. Lett. 108, 236402 (2012): many-body dispersion (MBD)
  • Ambrosetti, Reilly, DiStasio & Tkatchenko, J. Chem. Phys. 140, 18A508 (2014): MBD@rsSCS (range-separated screening; β for PBE, PBE0, HSE06)

Solvation and embedding

  • Cancès, Mennucci & Tomasi, J. Chem. Phys. 107, 3032 (1997): IEF-PCM
  • Klamt & Schüürmann, J. Chem. Soc., Perkin Trans. 2, 799 (1993): COSMO
  • Lin & Truhlar, J. Phys. Chem. A 109, 3991 (2005): redistributed-charge QM/MM boundary schemes

Geometry, frequencies and reaction paths

  • Pulay & Fogarasi, J. Chem. Phys. 96, 2856 (1992): redundant internal coordinates
  • Banerjee, Adams, Simons & Shepard, J. Phys. Chem. 89, 52 (1985): P-RFO saddle search
  • Bofill, J. Comput. Chem. 15, 1 (1994): Hessian update for saddle searches

Charges and properties

  • Breneman & Wiberg, J. Comput. Chem. 11, 361 (1990): CHELPG
  • Bayly, Cieplak, Cornell & Kollman, J. Phys. Chem. 97, 10269 (1993): RESP
  • Foster & Boys, Rev. Mod. Phys. 32, 300 (1960): Boys localization

MP2 family

  • Weigend, Häser, Patzelt & Ahlrichs, Chem. Phys. Lett. 294, 143 (1998): RI-MP2 auxiliary basis sets
  • Grimme, J. Chem. Phys. 118, 9095 (2003): SCS-MP2
  • Jung, Lochan, Dutoi & Head-Gordon, J. Chem. Phys. 121, 9793 (2004): SOS-MP2
  • Häser & Almlöf, J. Chem. Phys. 96, 489 (1992): Laplace-transform MP2
  • Takatsuka, Ten-no & Hackbusch, J. Chem. Phys. 129, 044112 (2008): minimax Laplace quadrature
  • Lochan & Head-Gordon, J. Chem. Phys. 126, 164101 (2007): orbital-optimized (opposite-spin) MP2
  • Bozkaya, Turney, Yamaguchi, Schaefer & Sherrill, J. Chem. Phys. 135, 104103 (2011): orbital-optimized MP2 algorithm
  • Lee & Head-Gordon, J. Chem. Theory Comput. 14, 5203 (2018): κ-regularized MP2
  • Dutoi & Head-Gordon, J. Phys. Chem. A 112, 2110 (2008): the terfc attenuator
  • Goldey & Head-Gordon, J. Phys. Chem. Lett. 3, 3592 (2012): attenuated MP2 (erfc, aug-cc-pVDZ)
  • Goldey, Dutoi & Head-Gordon, Phys. Chem. Chem. Phys. 15, 15869 (2013): attenuated MP2 in aug-cc-pVTZ (terfc)
  • Goldey & Head-Gordon, J. Phys. Chem. B 118, 6519 (2014): SCS-MP2(2terfc), separate attenuation of the two spin components
  • Goldey, Belzunces & Head-Gordon, J. Chem. Theory Comput. 11, 4159 (2015): MP2-V
  • Wang, Aldossary, Shi, Liu, Li & Head-Gordon, J. Chem. Theory Comput. 19, 7577 (2023): single-threshold local MP2 (the "WSHG23" scheme in the code)

Coupled cluster

  • Scuseria, Janssen & Schaefer, J. Chem. Phys. 89, 7382 (1988): CCSD
  • Hirata, Podeszwa, Tobita & Bartlett, J. Chem. Phys. 120, 2581 (2004): spin-adapted closed-shell CCSD equations
  • Raghavachari, Trucks, Pople & Head-Gordon, Chem. Phys. Lett. 157, 479 (1989): CCSD(T)
  • Rendell, Lee & Komornicki, Chem. Phys. Lett. 178, 462 (1991): closed-shell (T) algorithm
  • Carter-Fenk, J. Phys. Chem. A 129, 7251 (2025): LinLCCD(hh)
  • Ransford & Carter-Fenk, Phys. Chem. Chem. Phys. 28, 14428 (2026): ωB97X-L-V
  • Bartlett & Musiał, Rev. Mod. Phys. 79, 291 (2007): coupled-cluster theory (review)

Double hybrids

  • Grimme, J. Chem. Phys. 124, 034108 (2006): B2PLYP
  • Kozuch & Martin, Phys. Chem. Chem. Phys. 13, 20104 (2011): DSD-PBEP86

RPA, GW and excited states

  • Wilson, Gygi & Galli, Phys. Rev. B 78, 113303 (2008): iterative dielectric eigenpotentials (PDEP)
  • Eshuis, Yarkony & Furche, J. Chem. Phys. 132, 234114 (2010): RI-RPA and its frequency quadrature
  • Kaltak, Klimeš & Kresse, J. Chem. Theory Comput. 10, 2498 (2014): minimax imaginary-frequency grids
  • Hedin, Phys. Rev. 139, A796 (1965): the GW approximation
  • van Setten et al., J. Chem. Theory Comput. 11, 5665 (2015): GW100 benchmark (the MOLGW reference values)
  • Dreuw & Head-Gordon, Chem. Rev. 105, 4009 (2005): TDDFT and CIS (review)

Constrained DFT

  • Wu & Van Voorhis, J. Chem. Phys. 125, 164105 (2006): electron-transfer couplings from constrained DFT

Background on MP2's dispersion error

Not implemented in ferric; cited on Electronic response:

  • Cybulski & Lytle, J. Chem. Phys. 127, 141102 (2007)
  • Heßelmann, J. Chem. Phys. 128, 144112 (2008)
  • Pitoňák & Heßelmann, J. Chem. Theory Comput. 6, 168 (2010)

Software dependencies

License

Dual-licensed under either

at your option.

Golden path: input formats -> docking -> xtb -> DFT/QM-MM (2026-09-18)

Status of each claim is marked MEASURED (with source) or ESTIMATED (with reasoning). Two companion docs: golden-path-iteration-1.md (the survey that scoped this) and golden-path-qmmm-pipeline.md (the full QM/MM tier design).


STATUS AS OF 2026-09-19 — what has landed on main since this was written

C3 (transition-state search) is MERGED (#106) and now DEMONSTRATED under QM/MM embedding, not merely reachable. The TS cost model was also corrected from 2*6N + n_steps to 2*(6N+1) + (n_steps+1) (#110) -- a constant +3, so no conclusion moves, but the tables below are each 3 low.

This document was written against a main where several of its blockers were live. Ten PRs have merged since. VERIFIED against origin/main just now:

blocker as writtenstatus
context["geometry"] never written -> docked pose discardedFIXED (#93). _harvest_geometry present in funnel.py
No structure readers -- xyz onlyFIXED (#91, gro in #128). tools/structure reads PDB/mmCIF/PQR/SDF/mol2/gro/SMILES
normal_modes not exposed to Python -> C4 uncompletableFIXED (#97). Present in the bindings
No substitution enumerator wired to the pocketFIXED (#96). propose_substitutions + embed_proposals
Gradient memory 3.033 GB peakFIXED (#92). 7.4x lower
MPI job flakes ~7%FIXED (#98). Launch retried once
No dispersion at allFIXED (#99). Native D3(BJ), verified 2e-16 Ha
Correlated energies never reach a machine-readable logFIXED (#100). Each method's own total goes in a result record
build-and-test is a 63-min critical pathFIXED (#101). 4-way shard, 2466 tests, 8.3% spread
danuglipron flow exists only as scratch scriptsFIXED (#102). The substitution golden path runs end to end on 7LCJ

STILL OPEN, and these are the real remaining gaps:

  • No saddle search. CLOSED AND MERGED 2026-09-19 (#106): ferric_scf::saddle::find_saddle, P-RFO with a Bofill update. C0-C5 is complete, and it is now WIRED TO AND DEMONSTRATED ON the QM/MM evaluator -- NH3 umbrella inversion under point-charge embedding converges in 5 steps to exactly one imaginary mode (qmmm_saddle_converges.rs), wired to the QM/MM evaluator. IRC LANDED 2026-09-19: irc::follow_irc walks mass-weighted steepest descent off the saddle in both directions, so "which two minima does this connect?" is now answerable. NH3 inversion lands at +0.8044/-0.8044 Bohr pyramidalisation -- opposite sides, degenerate, barrier 0.016078 Ha both ways. Runs under QM/MM embedding through the same closure find_saddle takes, so the path and the search cannot disagree about the field. Section 4 has the full scope.
  • No analytic dispersion gradients. CLOSED 2026-09-19 (#99). ferric_d3::d3bj_gradient is implemented and validated against finite difference to 1e-12..1e-13 on four systems, with the step-size scan showing textbook O(h^2) convergence. task="optimize" and with_gradient=True now WORK; MEASURED end to end on Ar2/PBE/STO-3G from a short start, 6.7917 Bohr uncorrected vs 6.6418 with d3bj -- the attraction shortens the bond, which is the direction that shows the correction reached the GRADIENT and not only the energy. task="frequencies" and run_frequencies(xc=..., dispersion=...) also WORK on a closed-shell KS reference: the Hessian is the finite difference of the KS + dispersion gradient, validated against second differences of the D3(BJ) energy and of the full SCF + MBD@rsSCS energy (see Validation). Open-shell references are refused.
  • QM/MM dispersion. D3/D4/XDM/VV10 are all QM-atom-pairwise; MM point charges carry none. Needs LJ terms in ferric-mm.
  • Pose noise. MEASURED per-pose sd 29.07 kcal/mol against 1-2 kcal/mol substituent effects, and funnel.py keys one row per MOLECULE so it cannot express an ensemble. This gates the QM tier, not the cheap tiers.
  • Site dependence. MEASURED within/between ratio 0.94-0.95 on two independent constructions: WHERE a group goes matters as much as WHICH group. The pipeline's unit must be the (substituent, SITE) pair.

0a. THE ANSWER TABLE -- start here

Every row was EXECUTED on origin/main on 2026-09-19 to produce the cost in it; a row nobody could run is not in this table. Costs are one call, warm process, single-threaded, on this box -- read them as orders of magnitude, and see "the first call costs 24x" below before planning a campaign from them.

you want tocallcostthe plot that answers it
enumerate substitutionssubstitution.propose_substitutions7.6 ms / 7 proposals warm (248 ms first call)site_substituent_heatmap
screen toxicologytox.alerts.RdkitAlertsProvider.fetch4.9 ms / molecule, 13 endpointsliability_profile
rank analogues by liabilitytox.assess.assess_smiles -> .liability_score54 ms offline; 1.6 s with the default include_web=Trueliability_profile
dock a liganddocking.vina_dock31 s @ 57 atoms, 5.7 @ 21, 1.9 @ 9 (ex=4, 7LCJ; RESULTS.md M11 has 26.4 s/ligand)pose_ensemble, funnel_survival
relax a pose (FF)tiers.tier2_forcefield9 ms @ 21 atoms (2.2 @ 9, 8.2 @ 19, 21.6 @ 34)tier_comparison
relax a pose (xtb)tiers.tier3_gfn239 ms @ 21 atoms (0.152 s @ 9, 0.050 @ 19)tier_comparison
score with DFTtiers.tier4_dft2.6 s @ 9 atoms at the def2-svp DEFAULT (0.75 s at STO-3G; 613 s @ 71)tier_comparison
find a transition stateferric.run_saddle2*(6N+1) + (n_steps+1) gradientsenergy_profile
confirm it is oneferric.run_frequencies6N+1 gradientsimaginary_mode
get the barrierferric.run_irc~70 gradients / branchreaction_path
bind in a pocketactive_site.binding_energy137 s @ 71 atoms/6458 charges, STO-3G (TWO SCFs + pdb2pqr)pocket_polarization, site_substituent_heatmap
relax a geometry (QM)ferric.run_optimize1 gradient/step; 6 steps for an embedded methyloptimization_trace
set up a QM/MM cutferric.QmmmSystem + .with_boundary_chargesfree (setup)qmmm_partition
draw the moleculeviz.molecules.depict5.9 ms warm (88 ms first call)--

Tier 1 is now MEASURED here, not only cited. Every previous pass recorded that docking could not be re-measured on this box because mk_prepare_receptor.py was "missing". It was not -- it ships in the venv's bin/ and was simply not on PATH, so the check that found it absent was a PATH artifact rather than a missing dependency. Run 2026-09-20 against the 7LCJ pocket at exhaustiveness 4:

ligandatomsdock timeVina score
ethanol91.9 s-2.353
aspirin215.7 s (n=4, 5.5-6.0)-6.572
drug-scale5731.2 s-8.713

prepare_receptor itself is 3.1 s, once per receptor. The 26.4 s/ligand figure from RESULTS.md M11 sits inside this curve at drug scale and is CONFIRMED independently. Cost scales ~N^1.5 in atom count, so quoting one number hides a 16x spread across the sizes a campaign actually sees -- the row now gives three points.

pocket_polarization is the plot for a SINGLE pose (added 2026-09-20). site_substituent_heatmap ranks substituents across sites and says nothing about one calculation. compute_binding_energy also returns charges_vacuum and charges_field, and nothing plotted them -- so the one output that distinguishes an embedded result from a number went unlooked at. dq = q_field - q_vacuum is the pocket pushing electrons around the ligand: MEASURED on danuglipron/7LCJ, max |dq| = 0.037 e, summing to zero to 1e-13. The plot annotates that sum, because a NONZERO total means the two SCFs were not the same molecule and the interaction energy alone cannot show it.

binding_energy is TWO SCFs, not one tier. RUN end to end 2026-09-20: danuglipron's cryo-EM pose (71 atoms) in the 7LCJ pocket (6458 charges), STO-3G/RHF, 137 s wall, giving delta_e_kcal_mol = -17.41. The row used to say "tier 3/4 above", which is a pointer rather than a cost and does not add up from the tier rows: the call derives the pocket charges (2.66 s) and then runs the ligand SCF TWICE, embedded and in vacuum. At def2-SVP, scale by the tier-4 basis factor.

The number is for ONE POSE and does not rank analogues -- compute_binding_energy's own docstring carries the five closed protocols and the 4.07 kcal/mol ddE noise floor against 1-2 kcal/mol substituent effects. Pass that floor to site_substituent_heatmap(noise_floor=).

The DFT row is quoted at the DEFAULT basis now. tier4_dft's default is def2-svp, not STO-3G, and at 9 atoms that is 2.64 s vs 0.75 -- 3.5x (re-measured 2026-09-20, n=3, tight). Every STO-3G figure in tiers.py is labelled as such and none is wrong, but the leading number was the one a reader budgets with, and it was not the one a default call costs. Worth recording how this nearly went the other way: a bare run_dft(mol, sto3g, functional="pbe") takes 0.69 s while tier4_dft on the same molecule takes 2.64, which reads as ~2 s of wrapper overhead. Profiling put 2.667 of 2.667 s inside run_dft -- there is no wrapper cost at all, the two calls simply used different bases.

assess_smiles reaches the NETWORK by default. include_web=True adds the admetlab3 and protox3 providers, measured at 1.6 s/analogue against 54 ms for the offline rdkit-alerts path alone. Which one you pay decides whether a 1000-analogue sweep takes a minute or half an hour, and whether it runs offline at all.

The substitution row was RE-MEASURED 2026-09-20 (benzoic acid, {F, Cl}, the configuration that gives exactly 7 proposals): 248 ms on the FIRST call in a process, 7.6 ms on every call after it. The old flat "216 ms" was a cold-start number quoted as a per-call cost, which over-states a sweep of 100 analogues by ~30x. Both are given because both are real and they answer different questions -- budget the first call once, the rest at 7.6 ms.

The two tier rows were RE-MEASURED 2026-09-20 through the tier functions themselves (aspirin, 21 atoms, n=9 after a warm-up call): FF median 9.0 ms (min 8.8, max 10.3), GFN2 median 39.0 ms (min 35.4, max 44.0). They had read 32 ms and 53 ms with no provenance, and the FF figure was 3.5x high -- tiers.py measures 8.2 ms at 19 atoms and 21.6 at 34, which cannot bracket 32 ms at 21. Both rows now carry the size breakdown, because a single number hides that tier 2 and tier 3 scale differently with atom count. test_the_answer_table_tier_costs_agree_with_tiers_py pins them to the source.

Two rows carry a caveat that outweighs their cost.

Ranking by binding energy is not licensed: all four pose protocols are closed and the best available ddE noise is ~4.07 kcal/mol against effects of 1-2 (RESULTS.md M4-M14). site_substituent_heatmap(noise_floor=...) greys out every cell inside that limit precisely so a figure cannot imply otherwise. A paired estimator (M17) measures 0.221-0.615 in gas-phase MMFF and is PROVISIONAL -- untested in a pocket.

Toxicology discriminates between MOTIFS, not between substituents that leave the motif alone. MEASURED liability scores: benzene 0.000, aspirin 0.083 (phenol ester), nitroaromatic 0.167, catechol 0.208 (PAINS + NIH + BMS), Michael acceptor 0.250 -- a chemically sensible ordering. But a halogen scan around an unchanged scaffold gives every analogue the SAME score as the parent (13 aspirin proposals, all 0.0833), because the alerts are substructure matches and F/Cl/Me on a ring do not hit one. That is correct behaviour and it bounds the use: the gate REMOVES a liability-bearing motif from the set; it does not order the survivors. Every endpoint says so in its own note -- "a rank-only liability density, NOT a probability of toxicity".

xtb is a gate, not a ranker: Spearman 0.011 against DFT over a 3 kcal/mol span (M16, n=20, 95% CI [-0.434, +0.451]). Use it to separate the anion from the neutral, not to order two conformers.

The last two rows are the ones a catalyst user reaches for, and both exist because a number alone was not enough. run_optimize reports converged, steps and a final energy, which cannot tell "ran out of steps near a minimum" from "walked uphill and oscillated" -- optimization_trace draws the climb with its magnitude. And min_link_to_charge_distance() says a frontier is bad without saying WHERE: qmmm_partition shows the cut, the link atoms and the offending charge. Pass it expect_min_angstrom= -- it cross-checks your coordinates against that accessor and refuses a mismatch, because link_atom_positions() is ANGSTROM while point_charges() is BOHR and converting both errs by 1.89x in the SAFE direction.

"Has a plot" means the plot answers THAT question, not that a figure exists. The transition-state row is the example: a TS search produces an imaginary MODE (a 3N vector), and imaginary_mode shows whether it displaces the reacting atoms -- which is the second, non-optional half of confirming a saddle.

0. The headline defect: the docked pose is thrown away — FIXED 2026-09-19

RESOLVED. tools/pipeline/funnel.py::_harvest_geometry writes the key and is called from the driver (funnel.py:217). Chain re-verified on origin/main: tier1_dock returns symbols + coords_angstrom in its payload, _harvest_geometry writes context["geometry"][id], tiers._embedded reads it. The docked pose now reaches tiers 3 and 4.

Two things from the diagnosis are worth keeping, which is why the original write-up is left below rather than deleted.

FIRST, WHERE the write had to go. _run_stage dispatches through a ProcessPoolExecutor, so a tier that mutates context mutates a PER-WORKER COPY: the write is lost under the parallel path while appearing to work serially. Harvesting from the returned results in the driver is the only placement that holds for both. That generalises to any state a tier tries to pass forward.

SECOND, THE GREP BELOW CANNOT SEE ITS OWN FIX. Re-run today it still reports one read and no writes, because the write is context.setdefault("geometry", {})[...] and setdefault matches none of the three alternatives in that pattern. A grep that would have to be rewritten to notice the bug being fixed is not a verification you can re-run -- and this one was labelled "VERIFIED by grep, both directions".

The original 2026-09-18 write-up follows.

VERIFIED by grep, both directions, on 2026-09-18:

$ grep -rnE '\["geometry"\]|\.get\("geometry"|"geometry":' tools/ experiments/ --include=*.py | grep -v test
tools/pipeline/tiers.py:84:    cached = context.get("geometry", {}).get(iso.canonical)

One read, zero writes -- AS OF THE SURVEY. Fixed since; see the heading. funnel.py:184 now writes context.setdefault("geometry", {})[...] from _harvest_geometry. The paragraphs below describe the DEFECT as found, because the reasoning is what makes the fix legible; they are not the current state.

The consequence is concrete. tier1_dock DOES return the pose (tiers.py:181-185, payload carries symbols and coords_angstrom), and _embedded DOES look for a cached geometry (tiers.py:84) -- but no code connects the two. So _embedded falls through to tier2_forcefield, which re-embeds from SMILES with ETKDG in free solution.

Tier 1 costs 26.4 s/ligand (RESULTS.md M11, exhaustiveness 4). That entire spend was discarded, and tiers 3 and 4 then scored a gas-phase conformer that had never seen the pocket.

(Cost figures here cite RESULTS.md, not a file.py:NN line: a citation that resolves to a module doc comment is not a measurement, and the cost table below says so at length.) This violates the funnel's own stated premise -- _embedded's docstring says the cache exists so "tiers 3 and 4 would not be scoring DIFFERENT geometries of the same candidate".

This is independent of QM/MM. It is the single highest-value fix in the pipeline and should land before any new tier is added, because every downstream number is currently computed on the wrong geometry.

VERIFIED end to end, with and without the fix (2026-09-19)

Ran the REAL run_funnel with a geometry-producing tier 1 and a tier-4 stand-in that calls tiers._embedded -- the actual accessor tiers 3 and 4 use, not a proxy for it. The docked pose was set to an unmistakable [(9.9,9.9,9.9), (8.8,8.8,8.8)].

funnelwhat tier 4 received
origin/main (no fix)15 ETKDG coordinates -- a freshly embedded, free-solution geometry
with PR #93exactly [(9.9,9.9,9.9), (8.8,8.8,8.8)] -- the docked pose

The pre-fix arm is the important half: it is not that the pose arrives slightly perturbed, it is that a DIFFERENT MOLECULE GEOMETRY arrives, re-embedded from SMILES with a different atom count. Anything computed downstream of that describes a conformer the pocket never saw.

This is the A/B that makes the defect concrete rather than inferred from a grep, and it also confirms the fix is not vacuous -- the no-fix arm genuinely fails.

Where the fix goes (not inside a tier)

_run_stage (funnel.py:111-115) dispatches through a ProcessPoolExecutor, so a tier mutating context in a worker mutates a COPY -- the change never returns. Any fix that writes context["geometry"] from inside tier1_dock will silently do nothing under the parallel path while appearing to work serially.

The results DO return to the driver (funnel.py:158), so the harvest belongs in run_funnel's stage loop, between stages:

results = _run_stage(stage, population, context)
# harvest any geometry a tier produced, so later tiers reuse it
for r in results:
    if r.ok and r.payload and "coords" in r.payload:
        context.setdefault("geometry", {})[r.candidate_id] = {
            "symbols": r.payload["symbols"], "coords": r.payload["coords"],
        }

Anchor test before changing anything: assert that with tier 1 in the stack, tier 3 receives the DOCKED coordinates, not ETKDG's. The trivial-limit anchor is a funnel with no docking stage, where behaviour must be byte-identical to today.


0b. QUICKSTART -- the pipeline in code you can paste

Everything below this section describes the pipeline and costs it. This one RUNS it. Every snippet was executed on 2026-09-19 and its real output is shown; none is illustrative.

# A. any input format -> a ferric Molecule (Angstrom, seeded ETKDG for SMILES)
from tools.structure import from_smiles, read
mol = from_smiles("CC(=O)Oc1ccccc1C(=O)O", seed=0xF00D)   # aspirin: 21 atoms
mol = read("ligand_with_hydrogens.pdb")   # | .sdf | .mol2 | .xyz | .pqr | .gro
#   ^ a PLACEHOLDER path -- substitute your own file. Everything below runs
#     as written; this line is the only one that needs editing.
#   `read` RETURNS A MOLECULE. `read_structure` does NOT -- it stops at a
#   `Structure` (symbols/coords/charge/multiplicity/source) and never imports
#   ferric, which is what you want when inspecting a file without pulling in
#   the extension. Call `.to_molecule()` on it if you need one.
#
#   This doc named `read_structure` here and called it "the same entry point"
#   as `from_smiles`. It is not: pasting that line gives a TypeError on the
#   next `.symbols()`, because a Structure exposes symbols as a FIELD and a
#   Molecule as a METHOD. Verified 2026-09-19 by running it.
#
#   THE PDB MUST ALREADY HAVE EXPLICIT HYDROGENS. An ordinary
#   crystallographic PDB does not, and `read` refuses it with a
#   StructureError -- it does not protonate. That is correct (a species
#   missing its hydrogens is not the molecule), but it means "x.pdb" is not
#   a generic example, hence the filename above.
#
#   A RECEPTOR is the separate case: it goes through derive_pocket_charges,
#   which runs pdb2pqr and DOES protonate. That does not change `read`.

# B. enumerate analogues at every matching site
from tools.pipeline.substitution import propose_substitutions, relative_descriptors
parent = "c1ccccc1C(=O)O"
props = propose_substitutions(parent, {"F": "F", "Cl": "Cl", "Me": "C"})
#   -> 10 proposals, and props[0] is the PARENT. That is the anchor, not a bug:
#      the parent rides through every stage so scores can be reported as ddE.

# C. gate RELATIVE to the parent, never on absolutes
relative_descriptors(props[0].smiles, "c1ccccc1C(=O)O")
#   -> (-1.42e-14, 0.0, 0.0)   the parent against itself -- NOT exactly zero.
#      The distinction is real and worth knowing:
#        relative_descriptors(s, s)                     -> exactly (0.0,0.0,0.0)
#        relative_descriptors(props[0].smiles, s)       -> -1.42e-14 in dMW
#      because props[0].smiles is the CANONICAL form ("O=C(O)c1ccccc1") of the
#      input spelling ("c1ccccc1C(=O)O"). Same molecule, different atom order,
#      so the MW float sum lands one ulp apart. Compare ddE against a
#      tolerance, never `== 0.0`, whenever either side has been round-tripped
#      through a canonicaliser.

# D. liability flags (published alert sets -- NOT a probability of harm)
from tools.tox.alerts import RdkitAlertsProvider
provider = RdkitAlertsProvider()          # build ONCE: 47 ms vs 9.4 ms/molecule
eps = provider.fetch("CC(=O)Oc1ccccc1C(=O)O")
#   -> 13 endpoints, e.g. alert_brenk = 0.333
# E. the pocket half, composed -- all four tiers share ONE signature
#    (iso, context) -> TierResult, so run_funnel chains them.
from tools.pipeline import run_funnel, Stage
from tools.pipeline.tiers import tier1_dock, tier2_forcefield, tier3_gfn2, tier4_dft
from tools.campaign.hierarchy import Tier

stages = [
    Stage(Tier.FORCE_FIELD,   tier2_forcefield, keep=2, name="ff"),
    Stage(Tier.SEMIEMPIRICAL, tier3_gfn2,       keep=2, name="xtb"),
    Stage(Tier.QUANTUM,       tier4_dft,        keep=1, name="dft"),
]
# the funnel takes Isomers, and section B produced SubstitutionProposals --
# this conversion is the one line between them.
from tools.isomers.model import Isomer

candidates = [
    Isomer(
        smiles=p.smiles,
        kind="substitution",
        transform=p.label,
        parent_smiles=parent,
    )
    for p in props
]
rep = run_funnel(candidates, stages, {"seed": 0xF00D, "basis": "sto-3g"})
#   tier 1 is omitted above only because it needs the `docking` extra and a
#   receptor; add Stage(Tier.EMPIRICAL, tier1_dock, ...) with
#   context["receptor_pdbqt"] and ["box_center"] to run it.

MEASURED 2026-09-19, two small candidates, STO-3G, one process:

funnel wall: 1.58 s
  FORCE_FIELD    in=2 out=2 failed=0   0.03 s
  SEMIEMPIRICAL  in=2 out=2 failed=0   0.04 s
  QUANTUM        in=2 out=1 failed=0   1.51 s     <- 96% of the wall
survivors: ['CC(=O)O']   dft = -225.76133078 Ha

96% of the wall in the last tier on TWO candidates is the funnel's whole argument in one line, and it gets worse with candidate count: the cheap tiers scale with the population, tier 4 scales with what reaches it. That is why keep= matters more than any per-call cost in this note.

Note the DFT total (-225.76133078) is 4.18 mHa BELOW the SCF energy the ladder logs (-225.7571497). That difference is the D3(BJ) correction, -2.62 kcal/mol -- tier4_dft passes dispersion="d3bj" by default. It is a real contribution at chemical-accuracy scale, and it is why run_end.energy and a method's result.total are different numbers.

For the pocket half (docking, xtb, DFT) the entry points are tools.docking.vina_dock.dock_ligand, tools.campaign.fit.pose_fit and tools.active_site.binding_energy.compute_binding_energy; the composition is tools.pipeline.run_funnel, and tools/pipeline/tests/test_golden_path_smoke.py runs two real tiers through it end to end in about 7 seconds.

Before you rank anything with the output, read "WHICH METHOD FOR WHICH QUESTION" below. The pipeline will happily produce a ddE ordering that the measurements do not support -- five pose protocols have been tried and the best available noise is 4.07 kcal/mol against substituent effects of 1-2.


1. Input formats: what can enter

Rust Molecule has only load_xyz / load_xyz_with_charge / parse_xyz (crates/ferric-core/src/mol.rs). Everything else is Python-side.

formatreaderlayernotes
xyzMolecule::load_xyz, parse_xyzRustthe only native format
xyz (multi-frame)ConformerEnsemble.from_multi_xyzRustfrom_xyz reads ONLY frame 1
PDB / mmCIFtools/structure (gemmi)Pythongemmi is a CORE dep
PQRtools/active_site/pqr_parser, tools/structurePythoncharges are MM, not a QM charge state
SDF / mol / mol2tools/structure (rdkit)Pythonferric[docking] extra
SMILEStools/structure.from_smiles (rdkit ETKDG)Pythongeometry is tier-2 grade, NOT optimized
GROMACS grotools/structure (builtin)Pythonnm -> A; frame 1 only; cross-checked vs OpenMM
AMBER prmtopvia OpenMM onlyPythonno direct reader
OpenMMactive_site/mm_topology.topology_from_openmmPython

tools/structure (added 2026-09-18, PR #91) funnels every format through Molecule.from_xyz_string, so a PDB and an XYZ of the same geometry give bit-identical Bohr coordinates rather than drifting through two parsers.

What each entry path COSTS (MEASURED 2026-09-19)

The format table above says what can enter; these say what it costs. Min of 1-3 reps, single-threaded, on this box:

entry pathcostnotes
xyz -> Molecule (71 atoms)0.3 msthe native path; free
SMILES -> 3-D (from_smiles, ETKDG+MMFF)8.5 msper molecule
PDB -> PocketCharges (derive_pocket_charges, 7LCJ pocket)2.66 s6458 charges, pdb2pqr30

The remaining five paths, measured 2026-09-19 so the table covers what section 1 says can enter rather than only the three that had been timed. All one molecule (aspirin, 21 atoms) so the number is the PARSER and not the size, min of 5, single-threaded:

entry pathcost
xyz -> Molecule (21 atoms)0.10 ms
pdb (WITH hydrogens) -> Molecule0.11 ms
mol2 -> Molecule0.12 ms
multi-frame xyz -> 20-frame ConformerEnsemble0.23 ms
sdf -> Molecule0.33 ms
SMILES -> 3-D8.16 ms

Every file-parsing path is 0.1-0.3 ms and none of them matters. The only entry cost worth planning around is SMILES (~25-80x a file read, because ETKDG generates a conformer rather than reading one) and the one-off PDB->PQR at 2.66 s. A campaign that reads 1000 SDF files spends 0.3 s total on parsing.

THE FIRST from_smiles CALL COSTS 24x THE REST. MEASURED on aspirin:

first call   214.66 ms
min of 7       9.38 ms
median         9.75 ms

RDKit import plus ETKDG warm-up, paid once per PROCESS. The 8.5 ms in the table above is steady state and is right (re-measured: ethanol 2.38 ms, aspirin 8.52 ms) -- but a ONE-MOLECULE run pays 215 ms, not 8.5, and a per-molecule subprocess pays it every time.

This is why the per-item costs in this note are the wrong unit for planning a campaign: 1000 molecules in one process is 9.6 s of embedding, and 1000 subprocesses is 215 s. The difference is entirely warm-up, and it does not appear in any per-call figure.

END-TO-END through the real tier functions, aspirin (21 atoms), one process:

SMILES -> 3D (first call)   205.76 ms   <- warm-up dominates
xyz -> Molecule               0.21 ms
tier 2 MMFF                  24.67 ms
tier 3 GFN2-xTB              50.26 ms

Tier 2 at 24.67 ms sits between the table's 21.6 ms @ 34 atoms and the ~73 ms projected @ 71, so the projection holds at this size. Tier 3 at 50.26 ms is within the 0.05-0.152 s band.

Note the pdb row: a PDB that already has hydrogens reads at file speed. It is the crystal PDB with no hydrogens that is refused -- see the refusal note below, which is about protonation, not about the format being slow.

Four orders of magnitude separate them, and the ordering is the point: the PDB path is ~300x the SMILES path and ~9000x an xyz read. It is also a ONE-OFF per target -- PocketCharges is derived once and reused across the whole ensemble (that is the reason the type exists), so 2.66 s amortises to nothing over a 1000-analogue campaign and is a real cost for a one-molecule run.

Note from_smiles at 8.5 ms here vs 214 ms/proposal for embed_proposals in the cheap-stage table: same ETKDG machinery, ~25x apart, because a drug-sized analogue is far harder to embed than the small test molecule timed here. Quote the 214 ms for campaign planning.

A refusal worth knowing about before you hit it. read_structure REJECTS a crystal PDB with StructureError: ... no hydrogens. That is correct -- a PDB from the PDB has no hydrogens, and silently treating it as a QM molecule would hand the solver a species that does not exist. A receptor goes through derive_pocket_charges (which runs pdb2pqr and protonates), not through read_structure. The error names the problem, but the two paths are easy to confuse on first use.

PQR and the chain-ID column: a non-bug, CHECKED (2026-09-19). pqr_parser hard-requires exactly 10 whitespace fields and reads coordinates at fields[5:9]. PQR files that carry a chain ID have ELEVEN fields and shift the coordinates to fields[6:10], so such a file is rejected with Unexpected PQR field count (11, expected 10) -- and both repo fixtures are 10-field, so the 11-field layout is untested.

That looks like a gap and is not one. MEASURED: pdb2pqr30 drops the chain ID, emitting 10 fields even from a PDB whose ATOM records carry chain A. Fed a chain-bearing PDB through run_pdb2pqr, all 16 output records came back 10-field. The parser matches its only producer in this pipeline, and the 11-field layout is not reachable through derive_pocket_charges.

It IS reachable if someone hands you a PQR from another tool (APBS's own writers, some Amber paths). The failure is then a clean error naming the field count, not a silent misparse -- coordinates read from the wrong columns would be far worse. Pinned by test_an_eleven_field_pqr_is_refused_not_misparsed.

Unit hazard, worth stating once: Python geometry entry is Angstrom; point_charges and QmmmSystem.point_charges() are BOHR; PocketCharges holds Bohr. Mixing them is a silent 1.89x error, not a crash.

Charge and multiplicity are never inferred. No common structure format records multiplicity (2S+1) at all. ferric's parity check catches an odd-electron species left at the default singlet, but CANNOT catch an even-electron triplet (O2, many carbenes) -- that converges cleanly to a state that does not exist.


1b. Dependency status, CHECKED rather than assumed (2026-09-19)

Every stage below names a tool. Whether those tools are actually present is a separate question from whether the code is written, and it is the one that decides if a stage runs today.

dependencystatusused by
pdb2pqr30 3.7.1on PATHactive_site/pdb2pqr_runner (PDB -> pocket charges)
openmmimportableactive_site/mm_topology
vinaimportabletools/docking tier 1
meekoimportableligand prep for Vina
rdkitimportableSMILES/SDF/mol2, ETKDG, tools/isomers
gemmiimportable (core dep)PDB/mmCIF via tools/structure
xtbsee [[xtb-rollup]]tier 3

ONE FALSE ALARM WORTH RECORDING, because the same check will mislead the next person: import pdb2pqr FAILS (ModuleNotFoundError) while pdb2pqr30 is on PATH. That is not a broken install -- pdb2pqr_runner.py is a deliberate SUBPROCESS wrapper around the CLI ("Subprocess wrapper around the PDB2PQR CLI", line 1), so the module never needs to import. Checking importability would have reported a working stage as broken.

So the docking branch's tooling is complete. The blockers listed elsewhere in this document are about PHYSICS and DATA FLOW (missing dispersion, pose noise), not about missing software.

2. Cost estimates

WHICH METHOD FOR WHICH QUESTION (the table this note was missing)

Everything below this heading costs methods. This one says which to REACH FOR, and -- more usefully -- what resolution each can actually deliver on this campaign. A method that is cheap and cannot answer your question is not a bargain.

the questionmethodcostresolution it deliversverdict
where does this ligand sit?Vina dock~2 min/ligand @ ex=32, ~30 s @ ex=40.95 A redock (M9), 20/20 poses on-siteuse it
which pose is best?Vina scorefree (comes with the dock)r(score, RMSD) = +0.461; only 4/20 under 2.0 Ado not trust -- generates, cannot rank
is this geometry sane?MMFF942-22 ms/pose (9-34 atoms), ~73 ms @ 71adequate to declashuse it, for declashing only
how strained is this conformer?GFN2-xTB0.05-0.152 s/pose (9-19 atoms)143 kcal/mol anion/neutral split resolveduse it for coarse separation
which of these conformers is lowest?GFN2-xTB0.05-0.152 s/poseSpearman 0.011 vs DFT over a 3 kcal/mol span (M16, n=20); 95% CI [-0.434, +0.451]do not trust -- a gate, not a ranker
which analogue binds better by 1-2 kcal/mol?any of the above + ddE--ddE noise 4.07 kcal/mol at best (M4-M13)NO METHOD QUALIFIES
what is the SCF energy here?ferric RHF / KS-DFT96 s @ 32 atoms, 612 s @ 711e-8 Ha vs PySCF (RHF), 2e-8 (PBE/B3LYP)use it
does the pocket field change it?+ external_potential~1.0x up to ~1000 charges, 3.4-5.6x at 6458 (MEASURED)free at small charge counts, 3-6x for a whole pocket; a naive distance cut is NOT a safe way to shrink ituse it -- full pocket, or validate your cut
where is the transition state?saddle::find_saddle2*(6N+1) + (n_steps+1) gradientsconverges on a known saddle; refuses a minimum's basinuse it
is this really a TS?harmonic_frequencies6N+1 gradientsexactly-one-imaginary check, from Rust AND Pythonuse it
is dispersion missing from my DFT?[dft] dispersion = "d3bj"microseconds, energy AND gradienttwo-body D3(BJ), Z=1-103, vs simple-dftd3use it -- semilocal DFT has no London dispersion at all
...and optimize on that surface?same, task = "optimize"sameAr2 6.7917 -> 6.6418 Bohr (attraction shortens the bond)use it
...and get frequencies on it?----the FD Hessian from the D3 gradient is unvalidatedrefused, deliberately
which two minima does it connect?irc::follow_irc~70 gradients/branch (MEASURED, NH3)mass-weighted steepest descent both ways; endpoints agreed to 4 decimals across step 0.15/0.05/0.02, ASSERTED to a 0.02 Bohr banduse it
is this molecule a liability?tools/tox alerts9.4 ms/moleculepublished alert sets, NOT a probability of harmuse it as a FLAG

The row that matters most is the one with no method. Four pose protocols have been measured (RESULTS.md M4-M13) and the best available ddE noise is 4.07 kcal/mol against substituent effects of 1-2. Every OTHER row in this table is a green light; that one is not, and no amount of tier-4 DFT fixes it, because the error is in the pose ensemble and not the electronic structure.

Two rows are worth reading together. "Where does this ligand sit?" is a green light and "which pose is best?" is a red one, from the SAME tool. Docking generates the right answer among its candidates and cannot pick it out. That is not a defect to fix -- it is the empirical reason tiers 2-4 exist.

Embedding is free only while the charge set is small (CORRECTED 2026-09-19)

The row above read "~1.0x, embedding is essentially free". That is true for a handful of charges and FALSE for a real pocket. MEASURED, benzene/STO-3G against the 7LCJ pocket (derive_pocket_charges, 6458 charges, net -1.000 e):

n chargeswallvs vacuum
00.29 s1.00x
100.18 s0.62x
1000.18 s0.64x
10000.31 s1.06x
64580.97 s3.36x

(The sub-1.0 ratios at 10-100 are SCF iteration-count noise on a 0.2 s baseline, not a speed-up from adding charges. A separate warm run of the same pair gave 5.61x at 6458, so read the large-N cost as 3-6x rather than a single figure.)

So both numbers are right about different things, and the old row generalized the small one. A whole pocket is a real cost. The PDB->charges step itself is 2.46 s for 7LCJ and is paid once, not per candidate.

AND THE ANALOGUE MUST BE IN THE POCKET, which does not happen by itself. embed_proposals returns ETKDG conformers centred on the ORIGIN; the pocket sits at its crystal coordinates. MEASURED on 7LCJ: the embedded analogue's centroid is (0.00, 0.00, 0.00) A and the pocket's is (124.3, 148.3, 116.9) -- 226 A apart. Feeding those coordinates straight to an embedded SCF is not an error, it is a confident dE of -0.001 to +0.005 kcal/mol, i.e. a gas-phase answer wearing a QM/MM label.

This is the concrete shape of "harvest the docked pose" (section 0). The chain SMILES -> propose_substitutions -> embed_proposals -> run_rhf runs end to end and is WRONG without a placement step between the embed and the score. A dE of essentially zero against a charged pocket is the tell -- see the single-charge control under G3.

THERE ARE TWO PATHS TO A GEOMETRY AND ONLY ONE IS PLACED. Verified in the source, because the two look interchangeable from a call site:

pathcoordinate framesafe to embed?
dock_ligand -> DockedPose.coords_angstromthe receptor's (its docstring says so)yes
propose_substitutions -> embed_proposalsorigin-centred ETKDGno -- 226 A away

A DOCKED POSE IS UNITED-ATOM, so it is not a QM geometry as it stands. Vina merges nonpolar hydrogens into their carbons: MEASURED, aspirin docks as 14 atoms where 21 went in. tier1_dock now re-hydrogenates the pose via united_atom.restore_hydrogens before handing it on, and fails the candidate if it cannot -- because the failure downstream is silent in the worst place:

tieron a stripped pose
tier 4 (ferric DFT)FAILS -- the odd electron count trips the charge/multiplicity parity check. Protected by ACCIDENT
tier 3 (GFN2)does not. -35.492226 vs -39.621219 for the real molecule. Both plausible, neither errors, 2591 kcal/mol apart

AUDITED afterwards, because the obvious question is whether any other hop does this: it does not. read_structure (.sdf/.pdb/.xyz), from_smiles, tier2_forcefield and embed_proposals all preserve every atom, pinned by test_no_geometry_hop_silently_changes_the_MOLECULE. Docking was the only one, and only because PDBQT is a united-atom format.

funnel._harvest_geometry exists to carry the first into context["geometry"] so tiers 3 and 4 score the DOCKED pose instead of re-embedding. Use the funnel, or take coords_angstrom off the pose yourself. The embed path is for enumeration and gas-phase work; it is not a substitute for docking. embed_proposals' docstring SAYS so, with the 226 A figure.

But DO NOT truncate with a naive distance cut. That was the obvious next move and it is measured here because it does not work. Keeping charges within r of the probe, 7LCJ, water/STO-3G, error against the full 6458-charge answer:

r (A)keptdE (kcal/mol)err vs fullnet charge kept
662-1.070+1.815+0.845 e
8174-3.344-0.459+0.136
10337-2.849+0.036+3.552
12647-2.821+0.064+0.023
151280-1.967+0.918+1.699
202466-2.130+0.755+2.051
304025-2.005+0.880+1.367
full6458-2.8850-1.000

The error is NOT monotone in r -- 1.82 -> -0.46 -> 0.04 -> 0.06 -> 0.92 -> 0.76 -> 0.88 -- so "use a bigger radius" does not buy accuracy, and a 15 A cut is worse than a 10 A one. A sphere through a protein also cuts residues in half: the net charge kept wanders from +0.02 to +3.55 e against the full pocket's clean -1.000, and a spurious monopole is exactly the kind of error that does not decay with distance.

What is NOT established: that the error is CAUSED by the net charge. Spearman over these 7 points gives rho = -0.07 for |err| vs |net q| and +0.04 vs radius, and at n=7 the smallest detectable |rho| is ~0.75 -- neither comes close. The non-monotonicity and the charge wander are both MEASURED; the link between them is a hypothesis this sweep cannot test. A truncation scheme that cuts on whole RESIDUES (keeping each one neutral) is the standard fix and is untested here.

Until then: use the whole pocket and pay the 3-6x, or validate your own cut against it. err vs full is cheap to compute -- one extra SCF.

WHICH FUNCTIONAL AND BASIS (the other half of "which method")

The table above says which METHOD KIND to reach for. It never said which FUNCTIONAL or BASIS, which is the choice a tier-4 user actually makes -- tiers.py defaults to PBE/def2-svp and nothing explained why or when to depart from it.

Grades below are from wiki/VALIDATION.md, which is the authority; the worst measured error against PySCF is quoted rather than a tolerance, because a guard band says what a test permits and not what the code does.

functionalgradeworst error vs PySCFreach for it when
PBEProven (narrow)2.1e-8 Hathe default. Cheapest of the proven set, and the tightest agreement.
B3LYPProven (narrow)1.6e-8 Haa hybrid is wanted for barriers or charge transfer; ~exact-exchange cost over PBE.
LDAProven (narrow)5.9e-6 Haessentially never for chemistry -- 300x looser than PBE and it overbinds. Useful as a cheap smoke test.
wB97X-VProven (narrow)3.1e-5 Harange separation matters (long-range CT, some excited states). Note this is the LOOSEST of the four, 1500x PBE.
SCAN / r2SCANProven (narrow)1.95e-8 Hameta-GGA accuracy without exact exchange. r2SCAN over SCAN: SCAN's E(R) is non-smooth and stays so at (150,302) grids -- a known SCAN trait r2SCAN was designed to fix.

All five are validated on cc-pVDZ and def2-SVP only, four small molecules. Larger systems and other bases are unverified, which is a scope limit and not a prediction of failure.

The gradient story is narrower than the energy story, and it is the gradient that a geometry optimization or a saddle search depends on:

  • meta-GGA gradients are s/p-shell only -- ferric's AO Hessians do not go past 6-31G, so a SCAN optimization at cc-pVDZ is out of reach. d shells carry a ~6e-5 residual that the GGA path SHARES, so it is an AO-Hessian limit rather than a meta-GGA one.
  • there is no meta-GGA f_xc Newton kernel, so those SCFs fall back to DIIS.
  • there is no analytic Hessian for anything. Every Hessian in this note is finite-differenced at 6N+1 gradients, which is what makes the TS and IRC budgets what they are.

Basis costs ~10x, and almost every timing in this note is STO-3G. MEASURED 2026-09-19, RHF wall time on one process:

moleculeatomsSTO-3Gdef2-SVPratio
methanol60.03 s0.15 s4.4x
ethanol90.05 s0.54 s11.1x
benzene120.17 s2.39 s14.0x
aspirin212.35 s23.72 s10.1x

The EXPONENT is nearly unchanged -- tail-fitted 4.69 (STO-3G) against 4.10 (def2-SVP), global 3.48 against 4.04 -- so the basis is a near-constant MULTIPLIER over this range, not a steeper curve. That makes the correction easy and reusable: multiply any STO-3G figure in this note by ~10 to get what def2-svp costs, and keep the scaling exponent.

It matters because def2-svp is the tier-4 DEFAULT while STO-3G is what the end-to-end timings use. A campaign budgeted from the STO-3G rows is budgeted an order of magnitude light.

Basis. def2-svp is the tier-4 default and the larger of the two validated sets. STO-3G appears throughout this note because it is what the end-to-end timings use -- it is a demonstration basis, NOT a production one, and no number measured at STO-3G should be read as an accuracy claim.

ECP. def2-ECP is Proven (narrow) for RHF -- Xe 2e-12 Ha, I2 1.2e-6 -- so heavy elements are reachable, on three systems and one basis.

MEASURED (quoted with source)

tiermethodcostsource
1Vina, exhaustiveness 4 (the RECOMMENDED setting)26.4 s/ligand @ cpu=0 (12 cores); 109.0 s @ cpu=1RESULTS.md M11
1Vina, exhaustiveness 32, ~2 min/ligandsuperseded by the ex=4 row above; the figure was never measured and its tiers.py:11 citation pointed at the module doc comment
1Vina, per pose~10 ustools/docking/vina_dock.py:7

Budget from the ex=4 row, not the ex=32 one. M11 measured that across an 8x range of exhaustiveness the mean redock RMSD moved 0.097 A -- SMALLER than the 0.131 A between-seed SEM -- and ex=32 had the WORST mean of the four levels tried. So ex=32 costs 6.8x for no accuracy, and the ~2 min row is kept only because tiers.py:11 still documents it.

Note the parallel efficiency while you are here: giving up 11 of 12 cores costs 4.1x, so Vina's internal parallelism runs at 34%. Fan-out across ligands only wins above ~4 workers, which is a narrower claim than "the machine sits idle at low exhaustiveness". | 2 | MMFF94 (tier2_forcefield = embed + optimize) | 2.2 ms @ 9 atoms, 8.2 @ 19, 21.6 @ 34; ~73 ms projected @ 71 | MEASURED 2026-09-19 | | 2 | MMFF94 ~1 ms/pose | superseded: that figure cited tiers.py:12, which is the same doc comment -- a circular citation, never a measurement, and it described a single point rather than embed+optimize | | | 3 | GFN2-xTB via tier3_gfn2 | 0.05-0.15 s @ 9-19 atoms | MEASURED 2026-09-19 | | 4 | ferric DFT via tier4_dft | 0.66 s @ 9, 8.7 s @ 19 (STO-3G); 96.1 s @ 32 (def2-SVP, ~450 bf) | MEASURED 2026-09-19 / RESULTS.md | | 4 | ferric DFT | 612.4 s @ 71 atoms, STO-3G/PBE (~234 bf), 18 iters, converged | RESULTS.md |

DO NOT DERIVE A SCALING LAW FROM THOSE TWO ROWS. They differ in BASIS as well as size, and in the unhelpful direction: the BIGGER system used the SMALLER basis. Fitting them gives p = 2.32, which UNDERSTATES pure N-scaling because part of the size increase was paid for by a cheaper basis.

Measured directly, and the confound turns out to be small. A DFT N-sweep at FIXED basis (PBE/STO-3G, alkanes C2-C4, 2026-09-19) gives a tail exponent of 2.32 -- exactly what the confounded pair gives. Two independent routes to the same number:

routeexponentfixed basis?
the two rows above2.32NO (def2-SVP vs STO-3G)
PBE/STO-3G N-sweep (this)2.32yes
RHF/STO-3G N-sweep2.58yes
3->9 atoms via tier4_dft (2026-09-20)2.16 overall, 2.38 on the 6->9 tailyes

The last row is a DIFFERENT KIND of point and is here to stop a future session misreading it. Measured through tier4_dft: 0.25 s @ 3 atoms, 1.02 @ 6, 2.68 @ 9 -- so the table's "1.0 s @ 6 atoms" is exact. The OVERALL 3->9 exponent is 2.16, which looks like it undercuts the 2.3-2.6 band. It does not: these sizes are far below the 9-71 range the band is fitted on, and fitting the TAIL (6->9) gives 2.38, inside it. Averaging in the flat small-N start pulls the exponent DOWN, the same artefact this document flags for the QM-radius sweep below ("gives 2.51 by averaging in the flat small-R start").

So p ~ 2.3-2.6 is the honest band, and the basis confound moves the answer less than the RHF-vs-DFT difference does. Still quote the fixed-basis numbers, because the agreement is what makes the confounded pair usable rather than the other way round -- and note all three are 2-3 point tail fits, which is indicative, not decisive.

One measurement artifact worth naming: C1 came in at 5.30 s against C2's 0.37 s -- a first-call warm-up (basis parse, grid construction), not chemistry. Tail-fitting excludes it automatically, which is the reason the repo's protocol says to fit the tail rather than the whole series.

ESTIMATED (reasoning stated, do not quote as measured)

No QM/MM wall-time measurement exists anywhere in the repo -- the 9 Rust and 29 Python qmmm tests are PySCF CORRECTNESS tests on H2/ethane/water with no timings. So:

  • Tier 3.5 (in a pocket field): ~2x a gas-phase single point. RETRACTED 2026-09-18 -- MEASURED at ~1.0x, so the estimate was wrong by about 2x. See the measurement immediately below. I had reasoned "MM charges add a one-body term" and then guessed 2x anyway; the same reasoning actually PREDICTS a small overhead, because a one-body term is cheap. The guess did not follow from the argument I gave for it. QUALIFIED 2026-09-19: that ~1.0x holds up to ~1000 charges. A WHOLE pocket (7LCJ, 6458 charges) costs 3.4-5.6x -- the one-body term is cheap PER CHARGE and there are thousands of them. See "Embedding is free only while the charge set is small". The original retraction stands at the scale it was measured; it just does not generalize to an untruncated pocket.

  • Tier 5 (QM/MM DFT optimization): ~17-35 h in a real pocket (a ~5.8 h gas-phase floor x the 3-6x embedding cost); was "1.5-17 h". The 10x width came entirely from an unmeasured BFGS step count. MEASURED 2026-09-18: ethane with a link atom across the C-C cut (the catalyst shape) converges in 34 steps -- and in 34 at BOTH STO-3G and 6-31G, so the step count is set by the GEOMETRY, not the basis, as BFGS theory predicts. 34 x the 612 s single point = ~5.8 h. Still ESTIMATED, because the MULTIPLICAND is a 71-atom DFT single point measured on a different system; only the multiplier is now measured. CAVEAT, and it is the load-bearing one: 34 steps is a near-rigid CH3 relaxing a few bond lengths. A floppy ligand in a pocket has far more soft degrees of freedom and will take more. Treat 34 as a FLOOR for the step count, not a typical value -- one geometry is not a distribution. SECOND CAVEAT, added 2026-09-19: the 612 s multiplicand is a GAS-PHASE single point. A QM/MM optimization pays the embedding cost at EVERY step, and with a whole pocket that is not free. MEASURED, benzene/STO-3G against the 7LCJ pocket (6458 charges), same optimizer, same convergence:

    vacuum       4 steps, 0.278 s/step
    full pocket  4 steps, 1.574 s/step     -> 5.7x
    

    The STEP COUNT is unchanged, so the two factors MULTIPLY rather than trade off: ~5.8 h becomes ~17-35 h at 3-6x. Both caveats push the same way, so read 5.8 h as a hard floor built from a gas-phase multiplicand and a near-rigid multiplier -- not as an estimate of a real catalyst job.

    THIRD CAVEAT, and it cuts the other way (2026-09-20): the 3-6x embedding factor DOES NOT TRANSFER to a large QM region. It was measured on benzene, 12 atoms. MEASURED on danuglipron, 71 atoms, same 6458-charge pocket, RHF/STO-3G:

    systematomsvacuumembeddedratioABSOLUTE overhead
    benzene120.278 s1.574 s5.7x1.30 s
    danuglipron7143.1 s49.1 s1.14x6.0 s

    The pocket contributes roughly FIXED work -- 6458 charges folded into hcore once -- so the absolute overhead grows slowly (1.3 -> 6.0 s) while the QM SCF grows fast (0.28 -> 43 s), and the RATIO therefore FALLS with QM size. Applying benzene's 5.7x at 71 atoms overstates the embedding cost by ~5x. Use the absolute overhead, not the ratio, and never carry a ratio measured at one QM size to another.

    A worked consequence: an RHF/STO-3G QM/MM optimization of this 71-atom ligand in the full pocket MEASURED 77.8 s/step (6 steps, 466.9 s), so the 34-step floor is ~0.73 h, not 5.8. The 5.8 h figure is a KS-DFT number -- tier 4's 612 s multiplicand is tier4_dft, i.e. DFT with a grid, not the 49.1 s RHF single point measured here. Quote the one that matches the method you are actually running.

Every tiers.py:NN citation in this table pointed at a DOC COMMENT

Three of the cost rows cited tiers.py:11/13/14 or vina_dock.py:7. Every one of those lines is the module header's own cost table -- the citations pointed at prose, not at a measurement, and the golden path and the docstring were quoting each other.

A file.py:NN reference survives the check "is this sourced?", which is what let three unmeasured figures sit in a MEASURED table. They also rot: correcting the tier-2 line shifted the numbering, so tiers.py:13 and :14 came to point at the wrong rows entirely.

All three are now measured THROUGH THE TIER FUNCTIONS (not through the underlying library, which is a different cost):

tier9 atoms19 atoms34 atoms
2 tier2_forcefield2.2 ms8.2 ms21.6 ms
3 tier3_gfn20.152 s0.050 s--
4 tier4_dft (STO-3G)0.66 s8.7 s--

Tier 3's old ~0.5 s was the right order. Tier 2's ~1 ms was 20x low. Both were unmeasured; the difference is luck, not diligence.

When a cost in this table cites a file and line, open it. If the line is a doc comment, the number is unsourced.

Tier 2 is 20x costlier than claimed, and it changes nothing (MEASURED 2026-09-19)

The ~1 ms/pose figure cited tiers.py:12 -- which is the same doc comment. A circular citation, never a measurement, and it described a single MMFF point while tier2_forcefield does embed + optimize.

moleculeatomstier 2
ethanol92.2 ms
acetanilide198.2 ms
drug-like3421.6 ms

Tail exponent 1.66, projecting to ~73 ms at danuglipron's 71 atoms -- about 20x the claimed figure.

And the conclusion is unchanged. Tier 2 runs on the ~10% that survive docking, so at N=1000 it is 0.4% of the campaign against docking's 78%. Recorded because the next person to notice the discrepancy should not have to re-measure it to find out it does not matter: a wrong number that changes no decision is still worth fixing once, and worth marking as decision-neutral so it is not fixed twice.

The xtb -> DFT ratio is ~200x at drug scale, not 1000x (MEASURED 2026-09-19)

"DFT costs ~1000x" appeared twice as a STOP-HERE decision criterion. Measured on the SAME molecules (the only comparison that means anything -- the table's 0.5 s and 96-612 s rows came from different systems), GFN2-xTB relax vs PBE/STO-3G single point:

moleculeNxtbDFTratio
ethane80.024 s0.397 s16x
butane140.044 s1.374 s31x
hexane200.077 s3.619 s47x

The ratio GROWS with size, as N^1.17 on the last two points, because DFT scales ~N^2.3 while xtb is much flatter. Projecting:

Nprojected ratio
3281x
71 (danuglipron)206x
100307x
~2741000x

So the ~1000x figure is right only above ~270 atoms -- roughly 4x larger than anything this pipeline runs. At danuglipron's 71 atoms it overstates by ~5x.

The decision does not change: 200x is still a decisive reason to stop at tier 3 for a coarse sort. What changes is that the number is now measured, and anyone budgeting a campaign from it is out by 5x rather than in the right place by luck.

The CHEAP stages, measured at last (2026-09-19)

The cost table covered the quantum tiers and said nothing about the four stages that run before them -- enumeration, descriptors, embedding, toxicology -- even though those are what a substitution campaign spends its first hour on. All MEASURED on danuglipron (73 proposals from 8 substituent groups), min of 3-5 reps, single-threaded:

stagecostnotes
enumerate (propose_substitutions)2.8 ms / proposal207 ms for all 73
relative descriptors1.7 ms / proposalthe P2 gate
embed (embed_proposals, ETKDG+MMFF)214 ms / proposal~75x the other two combined
toxicology alerts (RdkitAlertsProvider.fetch)9.4 ms / molecule13 endpoints
tox catalog construction47 ms, ONE-OFFbuild the provider once

What this changes about where to worry. For 1000 analogues the whole cheap half is 1000 x (2.8 + 1.7 + 214 + 9.4) ms = ~3.8 minutes, of which embedding is 94%. Everything upstream of docking is free at campaign scale; the only cheap-tier stage worth optimizing is the ETKDG embed, and only if the campaign is much larger than 1000.

A docstring correction. RdkitAlertsProvider's own comment says per-molecule construction "dominates the runtime of a batch". MEASURED the ratio is 5x (47 ms build vs 9.4 ms/molecule), so constructing per molecule would cost 6x a batch, not orders of magnitude. Building it once is still right; the stated reason overstates the effect.

End to end: DOCKING dominates a substitution campaign, not DFT (2026-09-19)

Composing the measured per-stage numbers over a 10x-per-tier funnel (all -> 10% docked -> 10% xtb -> 1% DFT), single-threaded:

N analoguescheap halfdockxtbDFTtotal
1000.4 min0.6 h0.0 h0.2 h0.8 h
10003.8 min5.6 h0.3 h1.7 h7.6 h

Shares are scale-invariant at this funnel ratio. Recomputed 2026-09-19 with M11's MEASURED 26.4 s/ligand for tier 1 rather than the ~20 s estimate used first:

cheapdockxtbDFTtotal (N=1000)
with the ~20 s estimate0.8%73%4%22%7.6 h
with MEASURED 26.4 s0.7%79%3%18%9.3 h

The correction STRENGTHENS the conclusion rather than softening it: docking is 79% of the campaign, DFT 18%. Quote the measured row of the two above -- and read the basis caveat below before quoting either, because both rows are STO-3G and the split inverts at the default basis.

The funnel RUN end to end, at both bases (2026-09-20)

Not a model of the shares -- the actual pipeline, 10 substitution candidates of benzoic acid through dock -> FF -> xtb -> DFT against the 7LCJ pocket, keeping 6/4/2/1. Zero failures at either basis, same survivor (O=C(O)c1cccc(F)c1):

basistotaldockFFxtbDFT
STO-3G45.0 s72.7% (32.7 s)0.1%0.3%27.0% (12.1 s)
def2-svp (the DEFAULT)82.1 s39.8% (32.7 s)0.1%0.1%60.0% (49.2 s)

Two things this settles. At STO-3G the measured split is 73/27, which reproduces the modelled share above independently -- different system, different candidate count, same answer. And the basis caveat is REAL, not defensive: at the default the ranking inverts, DFT becomes the majority of the run, and "docking dominates, not DFT" stops being true.

So the headline holds only with the basis attached. Docking is the budget at STO-3G; DFT is the budget at def2-svp, which is what tier4_dft runs unless you say otherwise. The absolute docking cost is unchanged between the rows (32.7 s both times) -- it is DFT that moves.

That inverts the intuition this pipeline was designed around. DFT is the most expensive thing PER CALL by five orders of magnitude (6e+2 s vs 1e-5 s), and it is still only 18% of the campaign, because the funnel has already cut the population 100x by the time it runs. Docking is 79% -- it is cheap per pose and runs on EVERYTHING, 20 poses each.

AND IT INVERTS AGAIN AT THE PRODUCTION BASIS (2026-09-19). Both rows above use the 612 s DFT point, which is STO-3G. def2-svp -- the tier-4 DEFAULT -- costs ~10x that (measured under "Basis costs ~10x"), and nothing else in the table moves:

cheapdockxtbDFTtotal
STO-3G (the rows above)1%79%3%18%9.3 h
def2-svp (the default)0%30%1%69%24.4 h

So "docking dominates, not DFT" is true of a DEMONSTRATION basis and false of the default. The M11 conclusion below stands for what it measured -- tier 1 at production exhaustiveness -- but do not carry the 79/18 split into a production budget.

This is the same conclusion M11 reached from the other direction ("the funnel spent 2.6x more than it needed to", and the fix was tier-1 effort and fan-out, not tier 4). Two independent routes to "tier 1 is the budget" is worth more than either alone.

Practical consequence. The lever is exhaustiveness and worker fan-out at tier 1, both already measured (M11: ex=4 matches ex=32's accuracy at a quarter the cost; 10 workers x cpu=1 gives 6.2x). Optimizing the DFT tier -- the instinctive target -- can win at most 18%.

ESTIMATED, with the inputs labelled: the per-stage costs are MEASURED (above, and the hierarchy table), the funnel RATIOS are a design choice, and the DFT 600 s is a 71-atom single point from a different system. Change the ratios and the shares move; the ordering is robust to anything reasonable.

Transition-state search costs 2 Hessians + n_steps (MEASURED, 2026-09-19)

New entry: until ferric_scf::saddle landed there was no saddle search to cost. crates/ferric-scf/tests/saddle_cost.rs counts the actual calls rather than timing them, because a call count is a property of the algorithm while a wall time is a property of this box.

total = n_hessian * (6N + 1)  +  (n_steps + 1)   (gradient evaluations)

CORRECTED 2026-09-19 (was 2*6N + n_steps). harmonic_frequencies takes one energy-and-gradient at the UNDISPLACED geometry before its displacement loop, and n_gradient_evaluations is zeroed AFTER that call -- so the reported count is 6N while the true cost is 6N+1. find_saddle likewise takes one gradient before its first step. A constant +3 for a default search, so every conclusion below is unchanged and the tables are each 3 low.

Worth recording WHY it survived review, because the failure is reusable: the cost test supplies its own ANALYTIC Hessian closure and therefore never calls harmonic_frequencies at all. The documented model described the finite-difference path while every assertion measured a synthetic one. The test looked like it pinned the cost model and pinned something else. Fixed by a_finite_difference_hessian_costs_6n_plus_one_gradients, which runs the real FD Hessian.

MEASURED on an analytic surface whose saddle is known in closed form (hessian_recalc_every = 0, the default):

quantityvaluewhy
Hessians per search2one at the start (also the "is there anything to climb?" check), one at the end for the character check. None in between -- Bofill carries it.
gradients per step1
one Hessian6Ncentral difference of the analytic gradient; H2 = 12, water = 18

So a search on N = 20 atoms costs 242 + n_steps + 1 gradient evaluations, and the two Hessians dominate until n_steps exceeds ~240. That is the whole reason hessian_recalc_every defaults to 0; MEASURED, setting it to 1 doubles the Hessian work (2 -> 4 on this surface, i.e. 240 -> 480 gradient-equivalents at N=20).

The multiplicand, measured at three sizes (2026-09-19). The TS cost model 2*(6N+1) + (n_steps+1) had a measured MULTIPLIER and an unmeasured MULTIPLICAND -- one gradient, taken from a 71-atom DFT single point on a different system. Measured directly on linear alkanes, RHF/STO-3G, single-threaded:

N atomsone single point
5 (methane)22 ms
8 (ethane)29 ms
11 (propane)66 ms

Last-two exponent p = 2.58. THREE POINTS IS NOT A SCALING MEASUREMENT -- quote it as indicative, not as an exponent, and note the tail was fitted rather than the whole series (a global fit averages in the flat N=5->8 start).

Projecting t(N) = t(11)*(N/11)^2.58:

QM regionstepsgradientsprojected (STO-3G)
N = 11301620.2 min
N = 20302701.4 min
N = 201003401.8 min
N = 403051015.7 min

The step count barely matters and the BASIS dominates. Going 30 -> 100 steps at N=20 moves the total 26%; going N=20 -> 40 moves it 11x. And every row above is STO-3G, the cheapest basis there is -- a real catalyst at def2-SVP or better is orders above these. Treat the table as the N-SCALING SHAPE, not as wall times.

HOW to size the QM region

C0 says the QM region "sets the cost" and the section below says to size it first. This is HOW, and it is the first decision a catalyst user makes.

QmSelection offers three ways, and the choice matters:

variantuse it when
Indices(Vec<usize>)you have already decided, e.g. from a residue list
WithinRadius { seeds, radius }"ligand plus everything within R". Cuts mid-residue -- selection is by ATOM with no completion, which is what link atoms exist for
WithinRadiusWholeResiduesthe same, but a residue joins whole. Usually what a pocket setup wants

What a radius actually buys, MEASURED 2026-09-20 (danuglipron in the 7LCJ pocket, seeded on one ligand atom, from Python):

selectionQM atoms
qm_indices = the whole ligand71
qm_radius_angstrom = 2.05
qm_radius_angstrom = 4.015
qm_radius_angstrom = 6.0refused -- the sphere reached an MM charge

That refusal is the thing to know before you sweep a radius. A pocket point charge enters the structure as symbol "X" with z = 0; it has no basis functions, so it can never be quantum, and the sphere reaches one as soon as it leaves the ligand. The error now says so and names the knob:

QM atom 156 has symbol "X", which is not an element, so it cannot be in
the QM region -- a bare-charge site (z = 0) has no basis functions. ...
If you selected by radius, REDUCE qm_radius_angstrom until the sphere
holds only real atoms, or list the QM atoms explicitly with qm_indices.

It used to stop at "which is not an element", which is true, names the index, and still leaves you guessing whether the seed or the radius was wrong.

Two different enums share the name WithinRadius, and they answer opposite questions. QmSelection::WithinRadius picks which atoms are QUANTUM; MoveMm::WithinRadius(f64) picks which MM atoms are allowed to MOVE during an optimization. Confusing them gives a QM region of the wrong size or a frozen pocket, and neither fails loudly. Note also that MoveMm's radius is measured ONCE at the starting geometry -- a set re-evaluated as atoms move would change the coordinate vector's length mid-optimization.

What a radius costs. MEASURED on a uniform shell model (3 ligand atoms plus a 2-Bohr lattice), counting only -- no SCF:

 radius(Bohr)  QM atoms   TS budget 2*(6N+1)+31 grads   rel. DFT cost N^2.3
     3.0           29                  381                     1.0x
     4.0           50                  633                     3.5x
     5.0           95                 1173                    15.3x
     6.0          145                 1773                    40.5x
     8.0          333                 4029                   274x
    10.0          551                 6645                   873x

TWO exponents compound here, which is why this is the expensive knob. Atom count grows as R^2.63 (tail-fitted over the last three points; the global fit gives 2.51 by averaging in the flat small-R start, and a uniform shell would give exactly 3 by volume). Then DFT cost grows as N^2.3 on top. So 3 -> 10 Bohr is ~870x the cost PER GRADIENT while the gradient COUNT also grows 17x.

A uniform shell is the pessimistic case and this is a COUNTING model, not a chemistry one. A real pocket is not uniform -- solvent is stripped, the protein is not a lattice, and whole-residue completion changes the boundary -- so read the EXPONENT and the shape, not the absolute atom counts. What transfers is that radius is the dominant lever and that a Bohr is not a small unit here.

That is the practical guidance the cost model was missing: size the QM region first (golden path C0 already says it "sets the cost" -- this is by how much), and do not spend effort shaving P-RFO steps.

Putting a number on a catalyst TS. Using the same 612 s DFT single point the tier-5 estimate uses, and treating a gradient as ~1 single point:

TS search, N = 20, n_steps = 30    273 gradients   ~46 h
TS search, N = 20, n_steps = 100   343 gradients   ~58 h
IRC, both branches                 142 gradients   ~24 h
-------------------------------------------------------
TS + IRC, n_steps = 30             415 gradients   ~71 h

WALL CLOCK, measured against main 2026-09-19. The budget above is in gradient evaluations, which is the right unit for projecting -- but the whole chain had never been TIMED. NH3 umbrella inversion, STO-3G, one process, OPENBLAS_NUM_THREADS=1:

C3 saddle search     1.42 s    9 steps, is_transition_state() = True
C5 IRC (both)        2.87 s    63 + 63 steps, both converged
C6 barrier                     11.141 / 11.141 kcal/mol
------------------------------------------------------------
TOTAL C3 -> C6       4.29 s

The IRC is 67% of it -- 2x the search, not the "more than half" the gradient count predicts, because its steps are plain gradients while the search pays for two finite-difference Hessians AND the steps. Both ratios say the same thing for planning: budget the pair.

This is a 4-atom molecule at the cheapest basis, so treat it as proof the chain RUNS end to end from Python, not as a catalyst estimate. The projection below is the estimate.

The IRC is not a rounding item. At 142 gradients (MEASURED: 71 per branch on NH3 inversion, two branches) it adds more than half the TS search again, and a catalyst study needs it -- without it "exactly one imaginary mode" says the geometry is A saddle, not that it is the one connecting your reactant and product. Budget the pair, not the search alone.

ESTIMATED, and the multiplicand is the load-bearing weakness -- it is a 71-atom DFT single point measured on a different system. What is MEASURED is the multiplier (2*(6N+1) + n_steps + 1 for the search, ~71 gradients per IRC branch) and the 6N+1 Hessian cost. Note the step count matters much less than it does for a minimization: going from 30 to 100 steps moves the TS total by 26%, because the fixed 242-gradient Hessian cost swamps it.

What is NOT in this budget, so it is not mistaken for a full study: the reactant and product optimizations that precede the search, a frequency run at each endpoint for ZPE, and any conformational search over the QM region. Each is its own multiple of the same single-point cost.

The floor caveat, same as the BFGS one below. The step count above comes from an analytic two-atom surface. A real catalyst TS has soft degrees of freedom it does not. Treat any step count from this test as a FLOOR.

An EMBEDDED saddle search, demonstrated end to end (2026-09-19)

examples/qmmm_saddle.rs originally showed only a REFUSAL -- H2 in an MM field has no saddle, so find_saddle declines. That is worth showing, but it does not demonstrate the workflow WORKS: code that rejected everything would print the same thing. crates/ferric-scf/tests/qmmm_saddle_converges.rs now pins the positive half.

NH3 umbrella inversion under point-charge embedding: converges in 5 steps, exactly 1 imaginary mode, is_transition_state() = true, z spread 0.0075 Bohr (i.e. planar, which is the physically right answer).

Two findings came out of building it, both of which cost real time:

1. An MM field that BREAKS THE SYMMETRY DEFINING THE SADDLE does not make the search harder -- it removes the target.

fieldmax|g_z| at the planar geometryoutcome
gas phase~1e-16converges, 5 steps, 1 imaginary
symmetric (-0.2, -0.2)~1e-17converges, 5 steps, 1 imaginary; E shifted 5.0e-4 Ha
antisymmetric (-0.2, +0.2)3.8e-3runs to max_steps, converged = false

An antisymmetric pair puts a CONSTANT force along z, so planar NH3 stops being a stationary point at all. The search is right not to converge -- there is nothing there -- but it reads exactly like a solver bug. The tell that it is not step starvation: raising max_steps 60 -> 200 moved the energy by 2e-8.

Before debugging an embedded saddle search that will not converge, evaluate the gradient AT the symmetric geometry under the field. If it does not vanish, the field removed the saddle.

2. Relax every coordinate EXCEPT the one under study first. A textbook 1.01 A N-H left a bond-stretch gradient of 5.2e-3 that the convergence test (g_max 3e-4) rightly refuses; the STO-3G planar optimum is 1.006 A (g_max 6.1e-4). The search looked broken in a coordinate with nothing to do with the umbrella.

TS candidates REJECTED, recorded so they are not retried: linear H3+ and linear H2O are both SECOND-order saddles (2 imaginary, -1068 and -2328.7 cm^-1, each doubly degenerate). The bend of a linear molecule comes in a perpendicular pair, so "the linear form of a bent molecule" is almost never a transition state. NH3 is the smallest unambiguous closed-shell TS that is not already the starting geometry.

The negative half of that test is MUTATION-VERIFIED and is what makes the pair meaningful: setting external_potential: None passes the positive case -- the gas-phase saddle is right there -- and FAILS the antisymmetric one. Without it, a find_saddle that ignored the embedding entirely would look correct.

Point-charge embedding is essentially FREE (MEASURED, 2026-09-18)

Vacuum vs point-charge-embedded RHF on the SAME molecule (water), 8 MM charges of +/-0.5 e on a 6-Bohr shell, OPENBLAS_NUM_THREADS=1, min of 9 reps on an idle box (load ~1.0):

basisvacuumembeddedratioiterations
STO-3G10.6 ms11.2 ms1.06x8 -> 9
cc-pVDZ162.0 ms156.1 ms0.96x11 -> 11
def2-SVP35.1 ms32.9 ms0.94x11 -> 11

0.94-1.06x across three bases -- indistinguishable from free. The iteration count moved once (8 -> 9 at STO-3G) and not at all in the other two, so the "weak assumption" the retracted estimate worried about does not bite here.

The physics is unsurprising in hindsight: hcore_with_external folds the point-charge term into hcore ONCE before the SCF loop (see CLAUDE.md's external_potential notes), so embedding adds a fixed one-body cost and nothing per-iteration. Sub-1.0 ratios are timing noise, not speedups -- at 3 reps the STO-3G row read 0.74x, which is what prompted going to 9.

Consequence for planning: the MM environment is not what costs you. The QM region is, and specifically its GRADIENT peak -- see the next section. Do not shrink a QM region to "afford the embedding"; there is nothing to afford.

CAVEAT on scope, now RESOLVED by the sweep below: 8 charges on water measures the MECHANISM, not a protein-scale run.

...and it stays linear out to pocket scale (MEASURED, 2026-09-18)

Same QM region (water/cc-pVDZ), sweeping the MM charge count on an 8-Bohr Fibonacci shell. Min of 5 reps, OPENBLAS_NUM_THREADS=1, idle box.

chargeswallratioSCF itersms per charge
0 (vacuum)95.8 ms1.00x11--
891.1 ms0.95x11(noise)
6496.6 ms1.01x110.012
256115.4 ms1.20x110.077
1024176.0 ms1.84x110.078
4096403.7 ms4.21x110.075

Per-charge cost converges to 0.075-0.078 ms and stays there across three decades. A log-log fit over the last three points gives slope 0.993 -- linear to within 0.7%, which is what a one-body hcore term must be.

The SCF iteration count is 11 at EVERY charge count, vacuum included. The embedding field does not make the SCF harder to converge; that was the "weak assumption" behind the retracted 2x estimate, and it simply does not occur.

Extrapolating at 0.078 ms/charge (the 1000-charge row predicts 174 ms against a measured 176 ms, so the fit holds):

pocket sizepredictedvs vacuum
1,000 charges174 ms1.8x
10,000 charges876 ms9.1x

So embedding is free at a few hundred charges and becomes a real but modest cost at whole-protein scale -- and it is LINEAR, so it never overtakes the QM region's own N^3-N^4 scaling. Choose the QM region on its gradient peak (next section); choose the MM cutoff on physics, not on cost.

The DFT GRADIENT peak, not the SCF, is what sizes a QM/MM job (MEASURED)

New measurement, 2026-09-18, serial on an idle box (gradient_pool_peak_measurement.rs, 4 passed):

systembasisnbfSCF peakgradient peakratio
watercc-pVDZ240.000 GB0.103 GB--
benzene6-31G660.012 GB1.099 GB92x
benzenecc-pVDZ1140.035 GB1.860 GB53x
alkane_106-31G1340.162 GB5.930 GB37x
alkane_166-31G2120.503 GB13.879 GB27x

Two things follow for anyone sizing a QM/MM job.

1. Budget for the GRADIENT, not the SCF. The gradient peak is 27-92x the SCF peak that precedes it. A QM region whose SCF fits comfortably can still fail at the gradient step, which is the step every optimization and every one of the 6N+1 frequency displacements needs. Sizing a QM region from a single-point SCF is the wrong measurement.

2. alkane_16 is already at the edge. Its grid is TRUNCATED -- 405,038 of 412,500 points -- i.e. the unbatched path does not fit at 212 basis functions on this box. That is well below drug scale (danuglipron is ~73 atoms), so this is not a corner case.

Batching (PR #92) caps the gradient peak at the budget instead: benzene/cc-pVDZ 1.860 -> 0.250 GB (7.4x), benzene/6-31G 1.099 -> 0.250 GB (4.4x). The two water rows are unchanged at 1.0x by design -- batching engages only when the working set does not fit.

MEASUREMENT CAVEAT, learned the hard way: these tests carry #[ignore = "production-scale measurement; run serially on a quiet box"]. Run concurrently they FAIL, and the failure looks exactly like a code defect -- it is not. Satisfy the precondition (--test-threads=1, idle box) before believing either the numbers or a failure.

Frequencies / TS verification cost 6N+1 gradients (MEASURED)

harmonic_frequencies central-differences the ANALYTIC gradient, so a full Hessian is 6N+1 gradient evaluations (6N displaced plus one undisplaced, which n_gradient_evaluations does not count), each requiring its own converged SCF. There is no analytic Hessian to fall back on -- see section 4.

VERIFIED TWICE, and the second pass CORRECTED the first. Reading the loop gives for b in 0..n_coord over 3N coordinates with TWO energy_and_gradient calls inside (+delta, -delta), plus one at the undisplaced geometry before the loop -- from which I concluded 6N+1. That was wrong by one. The live counter FrequencyResult.n_gradient_evaluations reports exactly 6N:

systematomscounted
H2212
water318

The undisplaced call supplies result.energy and is not counted as a gradient evaluation; frequencies.rs:194 documents the field as 6N.

RE-CONFIRMED 2026-09-20 on four sizes, against merged main, and recorded here because the counter alone invites exactly the wrong correction:

systemNcounter6N6N+1
H22121213
water3181819
NH34242425
CH45303031

The counter is 6N at every size, and frequencies.rs:260 makes one energy_and_gradient call before the 0..n_coord loop's two per coordinate. So BOTH numbers are right for different questions: budget 6N+1 SCFs, expect the counter to say 6N. Someone reading only the counter will "fix" the 6N+1 figures in this document and understate every Hessian budget by one gradient.

Lesson worth keeping: reading a loop is better than trusting a docstring, but an EXPOSED COUNTER beats both -- it cannot drift from what the code did. energy_and_gradient takes (mol, basis_name, op, scf_config, reference) -- no density argument, so every one of those calls runs a complete SCF from the default guess.

Scaling the one MEASURED DFT point (612.4 s at 71 atoms, STO-3G/PBE):

  • a 20-atom QM region -> 120 gradient calls
  • a 40-atom QM region -> 240 gradient calls

ESTIMATED, and the multiplier is the honest part: each call is a fresh SCF, so the cost is 6N x (one converged SCF + one gradient), not 6N x (one gradient).

VERIFIED: frequencies.rs contains no guess-reuse or restart machinery -- grep for guess / initial_density / restart / previous finds only unrelated test comments. Every displaced SCF therefore starts from the DEFAULT GUESS, not from the converged undisplaced density, even though a delta-Bohr displacement perturbs the density negligibly. Seeding each displacement from the undisplaced converged density is a self-contained optimization that should cut the iteration count substantially on every one of the 6N calls. Unmeasured, but the mechanism is not in doubt: it is the same reason geometry optimizers carry the density between steps.

Two practical consequences for a catalyst workflow. First, TS verification is not a cheap afterthought appended to an optimization; on a realistic QM region it can dominate the whole job. Second, this is the strongest argument for wanting libint2 deriv_order=2: an analytic Hessian replaces 6N SCFs with one CPKS solve. That is a real speedup of something that WORKS, not an unblock -- unlike the missing saddle search, which blocks the workflow outright.

One structural result that IS solid

QM/MM memory is set by the QM region alone. MM point charges carry no grid points, so embedding a ligand in a whole protein costs the same AO cache as the ligand in vacuum. This follows directly from cost.py's model (grid scales with ATOM COUNT of the QM region) and is why QM/MM is affordable where a larger QM region is not.

But read it with the gradient measurement below: the quantity that must fit is the GRADIENT peak of the QM region, which is 27-92x its SCF peak, and a 212 basis-function region already truncates the grid unbatched. "QM/MM is affordable" means the MM environment is nearly free -- it does NOT mean a generous QM region is. The two statements are often conflated, and only the first is true.

Where the budget should NOT go (MEASURED, and counter-intuitive)

tools/campaign/hierarchy.py rule 6, from RESULTS.md M11: raising Vina's exhaustiveness from 4 to 32 cost 6.8x and improved the top score by 0.005 kcal/mol -- against a scoring function whose published RMSE is ~2.5 kcal/mol, a gain ~500x smaller than its own error bar. Redock RMSD across an 8x effort range moved 0.097 A against a 0.131 A between-seed SEM, so it was not resolvable either.

The sensitive variable was the STARTING CONFORMER (0.75-1.24 A across ETKDG seeds), so three seeds at the cheap setting beat one seed at the expensive one -- cheaper AND better sampled.

Two consequences for the cost table above. First, the "~2 min/ligand" tier-1 row is exhaustiveness 32; at exhaustiveness 4 with three seeds you get a better answer for less. Second, and more general: the measured tier costs tell you what a run WILL cost, not where to spend. Find the sensitive variable by measuring, then spend there.

Why the funnel exists at all (MEASURED)

Also from hierarchy.py: Vina reproduced the crystal pose at 0.95 A in ~2 minutes; the best any tier-3 method managed in 62 minutes was 2.41 A. But r(vina_score, pose RMSD) = +0.461, and only 4 of 20 poses were under 2.0 A -- so the cheap score FINDS the right pose and cannot reliably RANK it. That asymmetry is the empirical justification for tiers 2-4 existing: if tier 1 could pick its own best pose, no rescoring would be needed.

This is also the cautionary tale for the geometry defect in section 0. The danuglipron campaign ran tier 3 alone on free-solution conformers -- no tier 1, so it never searched -- and spent four rounds of increasingly careful statistics on a metric fed geometries 2.2-4.1 A from the binding mode. The unwritten context["geometry"] reproduces exactly that failure inside a pipeline that LOOKS like it has a docking tier.

Which dispersion model, once one exists (researched 2026-09-19)

D3(BJ) is IMPLEMENTED (#99, merged), energy and analytic gradient both. The comparison behind that choice, because "add dispersion" has four plausible answers and they are not equivalent:

modelneeds from the SCFcoststatus in ferric
D3(BJ)geometry + Z only, not even a densitynegligibleimplemented (#99, merged): energy + analytic gradient; task="frequencies" still refused, the FD Hessian from it is unvalidated
D4geometry, Z, EEQ chargesnegligiblenone; reuses nothing ferric has
XDMrho, grad-rho, tau, grad^2-rho on a grid + Hirshfeld weightsnegligible vs the SCF~80% present, see below
VV10rho, grad-rho INSIDE the SCFO(N_pts^2) pair sumimplemented, inside specific functionals

Accuracy is not the discriminator. Published S66 MAE (kcal/mol): BLYP-D3 0.19 vs BLYP-XDM 0.19; B3LYP-D3 0.28 vs B3LYP-XDM 0.22. The 2026 GMTKN55 paper states D3(BJ), XDM(BJ) and XDM(Z) "perform similarly". Anyone claiming a clear winner on dimer binding energies is overreading the data.

Name collision worth knowing. D3(BJ) uses Becke-Johnson DAMPING but Grimme's C6 table. XDM is Becke & Johnson's own MODEL, deriving C6/C8/C10 from the exchange-hole dipole moment. They are different things that share names.

ferric is unusually close to XDM. Each ingredient VERIFIED present:

XDM needsferric has
tau (Eq. 48)eval_tau_closed / eval_tau_uks
AO Hessians -> grad^2-rhoeval_basis_grad_hess_on_points
V_A = int r^3 w_A rho -- literally XDM Eq. 45atomic_effective_volumes_becke (properties.rs:478)
free-atom alpha, Z=1-54ts_free_atom
grad^2-rho assembledABSENT -- the one real gap

So XDM Step 3 is already implemented and tested here. The gap is assembling grad^2-rho from Hessians that exist, plus a scalar Newton solve per grid point. The reference implementation, postg (Otero-de-la-Roza & Johnson), is GPL-3.0 and a PROGRAM not a library -- no permissively-licensed XDM library exists in any language, and it is in neither PySCF nor Psi4. That argues for a native implementation rather than against one.

A dispersion correction does NOT fix QM/MM. D3/D4/XDM/VV10 are all QM-atom-pairwise. ferric feeds MM atoms in as point charges carrying no dispersion at all, so QM-MM dispersion is a SEPARATE gap -- probably LJ terms across the boundary in ferric-mm. Easy to assume away, so stated explicitly.

The label "DFT + dispersion" -- FIXED and MERGED 2026-09-19

crates/ferric-d3 implements two-body D3(BJ) natively, Z=1..103, with ZERO new dependencies. Tier 4 now defaults to dispersion="d3bj", so the label three places carry is finally true.

INDEPENDENTLY VERIFIED by me, not taken from the implementer's report: same water geometry through ferric and through live simple-dftd3 1.6.0.

functionalsimple-dftd3 1.6.0ferricdelta
PBE-3.594687655702e-4-3.594687655704e-42e-16 Ha
B3LYP-5.738758352593e-4-5.738758352595e-42e-16 Ha

Machine precision. 21 Rust tests pass. The reference tables are GENERATED by generate_tables.py from upstream s-dftd3 Fortran rather than hand-copied, so they are reproducible and auditable; CONTRIBUTING.md gained a separate "Derived data (not linked)" section, because the crate links nothing but its tables still derive from LGPL-3.0 source -- an obligation the existing linking table could not express.

WHAT IS STILL MISSING, stated because a dispersion correction invites the assumption that everything dispersive is now handled:

  • No analytic gradients. CLOSED (#99), and this line was wrong even before that -- task="optimize" was REFUSED, never silently uncorrected. Recorded because a stale "silently wrong" claim is worse than a stale "missing" one: it invites a reader to distrust results that were never produced. The gradient's load-bearing subtlety, since it is easy to reimplement wrongly: C6_AB is interpolated by both atoms' COORDINATION NUMBERS, so moving atom X changes C6 for pairs that do not contain X. Omitting that chain rule costs ~1e7x in FD error on real molecules (water 1.3e-6, CF2ONH 3.1e-4) and EXACTLY NOTHING on a dimer -- Ar2 is unchanged to 1.46e-13. A dimer-only test cannot see it.
  • No ATM three-body term. Absent rather than approximated -- there is no knob that does nothing. MEASURED contribution rises with system size: water 0.0001% -> benzene 0.1003% of the two-body energy. Extrapolating that trend to drug-scale ligands is explicitly NOT done.
  • QM/MM dispersion is still unfixed. D3 is QM-atom-pairwise; the QM-to-MM-point-charge gap needs LJ terms in ferric-mm.

The label "DFT + dispersion" was wrong before that

hierarchy.py:15 and vina_dock.py:8 both label tier 4 "DFT + dispersion". Grep for D3 / D3(BJ) / Grimme / dftd3 across ferric-dft, ferric-scf and ferric-python finds NO dispersion correction. VV10 exists inside specific functionals, not as an add-on for PBE. ferric_d3.py wraps the external dftd3 package but is a loose helper, not wired into the tier.

Dispersion is the DOMINANT attractive term in ligand binding, so this is not a cosmetic mislabel: tier 4 as run is missing the physics its own label claims.


3. The golden path, as an ordered list

Decision points are marked. Steps 1-6 are available today; step 7 is blocked (see section 4).

  1. Ligand in. SMILES -> tools.structure.from_smiles; a file -> tools.structure.read. State charge and multiplicity explicitly.
  2. Receptor in. PDB -> pdb2pqr (active_site/pdb2pqr_runner) to add hydrogens and assign MM charges. Do NOT feed a crystallographic PDB directly; tools.structure refuses one with no hydrogens for this reason.
  3. Tier 1, dock. ~2 min/ligand. The pose is a HYPOTHESIS -- run redock_rmsd against a known bound pose on your target first, or you do not know the search works.
  4. Harvest the pose into context["geometry"] -- see section 0. Without this, everything below scores a gas-phase conformer.
  5. Tier 3, xtb. 0.05-0.152 s (MEASURED, 9-19 atoms). DECISION: stop here? If you are rank-ordering many ligands and only need a coarse sort, GFN2 is often enough. Going to DFT costs ~200x per candidate at this scale (MEASURED; ~1000x only above ~270 atoms).
  6. Tier 3.5/4, DFT. DECISION: gas phase or embedded? Gas-phase DFT on a docked pose ignores the pocket electrostatics entirely. Use the pocket field (context["point_charges"], already consumed at tiers.py:196 and tiers.py:249) when the pocket is charged or polar. DECISION: do you trust the number? See the pose-noise caveat below.
  7. Catalyst / barrier work. BLOCKED AVAILABLE since 2026-09-19 -- ferric.run_saddle -> run_frequencies -> run_irc, all three taking point_charges=/external_field= so the whole chain runs on ONE surface. Section 4 has the cost model and section 3b(b) the decision procedure. DECISION: is your QM region right? That is the choice that sets the cost -- see "how to size the QM region", and note the frontier trap: keeping the host MM charge across a covalent cut puts a point charge 0.443 A from the link atom and the optimization DIVERGES. Use delete-host at minimum.

The pose-noise caveat, which decides whether step 6 is worth running

funnel.py:162 keys results on iso.canonical -- ONE row per MOLECULE. But QM/MM binding energy is a property of a POSE. RESULTS.md M5/M6 MEASURED a per-pose sd of 29.07 kcal/mol and a within-molecule/between-molecule spread ratio of 228 vs 41 kcal/mol; M6 computes that resolving a 0.25 kcal/mol gap needs ~108,000 poses per candidate.

A one-pose-per-ligand QM/MM tier therefore reports a number whose noise is orders of magnitude above the distinction being asked of it. Decide the pose treatment (ensemble? Boltzmann weight? best-N?) BEFORE writing the adapter, because the data structure the funnel uses cannot currently express it.


3b. Decision procedure: what a QM/MM workflow should DO

The two workflows are NOT the same problem and must not share a recipe.

(a) Docking / binding affinity

The question is a RELATIVE energy between ligands, so error cancellation does most of the work and the QM region can be modest.

G0. POSE-QUALITY GATE -- before scoring anything.
    tools.campaign.align.pose_quality_gate(aligned, threshold=2.0 A)
    Redock a KNOWN complex; if no pose clears the bar, STOP. Nothing
    downstream can recover, and this is precisely the check whose absence
    cost the danuglipron campaign four measurement rounds.
G1. Dock (tier 1), then HARVEST the pose into context["geometry"] (section 0).
G2. Rank with GFN2-xTB in the pocket field (tier 3 + point_charges).
    DECISION: if you only need a coarse sort, STOP HERE. DFT costs ~200x at
    danuglipron scale (MEASURED, see below).
G3. QM region = the ligand. Pocket = MM point charges. No link atoms needed
    when the cut does not cross a covalent bond -- which for a non-covalent
    ligand it does not. This is the case ferric handles cleanly today.
    HOW MUCH does the field matter? MEASURED, water/STO-3G vs vacuum:

        one -0.5 charge at 3.2 A      -5.97 kcal/mol
        one -0.5 charge at 2.1 A     -12.89
        one -1.0 (Asp-like) at 2.6 A -17.39

    TENS of kcal/mol for a charged residue in contact range -- far larger
    than the substituent effects a campaign tries to resolve. Embedding is
    not a refinement here; omitting it changes the answer.

    BUT CHECK YOUR POCKET MODEL IS EXERTING A FIELD AT ALL. A symmetric or
    antisymmetric charge arrangement can cancel almost exactly at the
    ligand: MEASURED -0.002 kcal/mol for a +-0.4 pair at +-4.2 A, against
    -5.97 for a single -0.5 at 3.2 A. A near-zero embedding shift usually
    indicts the MODEL rather than showing the pocket does not matter, so
    compare against a single-charge control before concluding it is free.
G4. Report a DIFFERENCE (dG_bind between ligands, or vs a reference ligand),
    never an absolute. The absolute carries the full method error; the
    difference is what error cancellation protects.

What one G0-G4 pass COSTS, per ligand. The catalyst branch has a closed form (2*(6N+1) + (n_steps+1) gradients); this branch had per-stage numbers scattered across the note and no way to add them up. MEASURED 2026-09-19, one process, single-threaded:

ligandatomsG1 dockG1 relax (MMFF)G2 xtbG3 DFT (STO-3G)
ethanol926.4 s**130 ms*20 ms2.6 s
aspirin2126.4 s**12 ms46 ms62.6 s
paracetamol-like3426.4 s**23 ms76 ms265.3 s

**Docking is the same column as the formula below, so the table and the budget have one scope. 26.4 s/ligand at exhaustiveness 4 on 12 cores (RESULTS.md M11); it is quoted per ligand rather than per atom because Vina's cost is driven by the search, not by the atom count over this range.

*The ethanol FF number is LARGER than aspirin's on a SMALLER molecule because it is the first call in the process -- the RDKit/ETKDG warm-up documented under "the first from_smiles call costs 24x". Read 12-23 ms as the steady state.

So the budget for N ligands through G0-G3 is

N * (26.4 s docking + ~0.02 s FF + ~0.05 s xtb) + N_survivors * DFT

and DFT is the only term whose exponent hurts: 2.6 -> 62.6 -> 265.3 s across 9 -> 21 -> 34 atoms. Fitted on ATOM COUNT the exponent is 3.0 (21->34), 3.75 (9->21), 3.48 globally -- steeper than the ~N^2.3 measured elsewhere in this note, and the difference is the axis, not a contradiction: that figure is in BASIS FUNCTIONS at fixed basis, and these three molecules differ in composition as well as size, so atom count is the cruder axis. Quote whichever you fit, and say which. Everything before DFT is flat by comparison.

The practical consequence depends on the BASIS, and that is easy to get backwards. At the STO-3G numbers in the table, docking 100 ligands costs 44.0 min and DFT on 10 survivors at 34 atoms costs 44.2 -- comparable, which says "choose how many reach tier 4 before optimizing what tier 4 does".

At the tier-4 DEFAULT basis that conclusion inverts. def2-svp costs ~10x STO-3G (measured above), so the same 10 survivors cost 442 min against docking's 44:

docking 100DFT 10 survivorsDFT share
STO-3G (the table above)44.0 min44.2 min50%
def2-svp (the default)44.0 min442 min91%

So for a production run, tier 4 IS the budget and making it cheaper is where the work is. The STO-3G reading is right only for a demonstration basis. This is the same trap as reading any STO-3G row here as a production cost.

No pose treatment works (RESULTS.md M4-M14). Ensemble, Boltzmann weight and best-N were each measured:

treatmentddE noisevs a 0.25 kcal/mol gap
more poses, averaged (M4/M5)4.0716x
relax in field then average (M6)~4.1~16x
dock then average (M12)~4.1~16x
select the top-docked pose (M13)40.66163x
a different scorer (M14)4.68 best tracking19x

So funnel.py:162's one-row-per-MOLECULE keying is not the blocker it was written up as -- no pose treatment the data structure could express resolves a 1-2 kcal/mol substituent effect. G2 is the last step whose output is trustworthy. Its own "STOP HERE if you only need a coarse sort" is now the recommendation rather than an option, and G4's dG_bind difference is reportable only when the gap is large (>~5 kcal/mol, i.e. outside the measured noise), not for lead optimisation.

This does NOT weaken G0-G3: the pose is found reliably (M9, 0.95 A redock) and the coarse sort works. It bounds what G4 may claim.

G1-G4 VERIFIED END TO END on merged main (2026-09-18)

Not a plan -- run against origin/main after PR #91 landed the readers:

PDB -> Molecule : 3 atoms, 10 electrons, ['O', 'H', 'H']
vacuum RHF      : -74.72406147 Ha, converged=True, 14 it
embedded RHF    : -74.74153533 Ha, converged=True, 12 it
G4 difference   : dE = -0.017474 Ha (-10.97 kcal/mol)

Every hop the docking branch claims is real today: tools.structure.read takes a PDB (explicit charge/multiplicity, no guessing), hands a Molecule to run_rhf, the same QM region runs again with point_charges=, and the answer reported is a DIFFERENCE rather than an absolute. Both SCFs converged.

The script is /tmp scratch, not committed -- it is four calls and is reproduced above in full effect. What matters is that it was RUN, so the (a) branch below is a description of working code rather than an intention.

The (b) branch is too, as of 2026-09-20. run_saddle landed on 2026-09-19, and every C-step was then run against merged main as two smoke tests on two systems, not one continuous C1-C5 chain -- see "Every C-step RUN against merged main" under (b).

The same thing from the CLI, no Python (2026-09-19)

G3 no longer requires writing a script. A [qmmm] TOML section drives the same path -- the QM region becomes the molecule that is solved, the MM region becomes the external potential it is solved in:

[qmmm]
pqr = "testdata/molecules/water_na.pqr"
qm_indices = [0, 1, 2]            # or: qm_seeds = [0], qm_radius_angstrom = 1.5
# link_bonds = [[0, 3]]           # required when the cut crosses a covalent bond
# boundary_scheme = "delete-host" # default; also "keep", "rc", "rcd"

RUN, not described (examples/water-qmmm.toml, water + one Na+ at 4 A, STO-3G):

[ferric] QM/MM: 3 QM atoms, 1 MM charges from testdata/molecules/water_na.pqr
embedded : -74.9653197421 Ha
vacuum   : -74.9629466809 Ha
G4 dE    : -0.002373 Ha = -1.489 kcal/mol

A geometry comes from the PQR, not from [molecule] xyz. The xyz key is still accepted and still ignored when [qmmm] is present -- a PQR carries BOTH coordinates and MM charges, and an xyz carries no charges, so the PQR has to win. This bites when computing the vacuum reference for G4: deleting the [qmmm] section makes the run fall back to the xyz, and if that file holds a different geometry you get a different molecule. Here water.xyz is an optimized HF/cc-pVDZ structure and gives -74.9631468000, which is NOT the vacuum energy of the embedded geometry and would put a 0.13 kcal/mol error straight into the difference.

For a G4 difference, take the vacuum number at the SAME geometry -- write the PQR's QM atoms out as an xyz, or call run_rhf twice with and without point_charges=. charge and multiplicity under [molecule] DO still apply, to the QM region.

(b) Catalyst optimization

The question is a BARRIER, i.e. a SADDLE POINT. Error cancellation does not save you, and the QM region must contain the reacting bonds.

C0. QM region MUST contain every bond that breaks or forms, plus any residue
    donating/accepting a proton or coordinating the metal. This is bigger
    than a ligand-only region and sets the cost.
    AND: check the MM field does not break a symmetry that DEFINES your
    saddle. MEASURED -- an antisymmetric charge pair makes planar NH3
    non-stationary (max|g_z| 3.8e-3 vs ~1e-16 in gas phase), so the search
    fails for want of a target and looks like a solver bug.
C1. The cut WILL cross covalent bonds, so link atoms are mandatory:
    .with_link_atoms(bonds, DEFAULT_LINK_SCALE) and a boundary-charge scheme
    (Z1/RC/RCD). ferric has all of these, PySCF-validated.
C2. Optimize the reactant and product complexes -- ferric CAN do this
    (optimize_qmmm is a minimizer).
C3. FIND THE TRANSITION STATE. AVAILABLE AND MERGED (#106, 2026-09-19) from
    BOTH languages: ferric_scf::saddle::find_saddle (Rust) and
    ferric.run_saddle(mol, basis, ...) (Python). The Python binding
    matters because the whole tools/ pipeline is driven from Python --
    without it C3 existed in a language the pipeline does not speak.
    See section 4.
C4. Verify the TS: n_imaginary == 1, AND the imaginary mode must point along
    the reaction coordinate (one imaginary frequency is necessary, not
    sufficient -- a methyl rotor gives one too).
    WHICH OBJECT: `n_imaginary` and `is_transition_state()` are on
    **SaddleResult** (from `run_saddle`). **FrequencyResult** (from
    `run_frequencies`) has no `n_imaginary` -- it exposes `frequencies` as a
    PROPERTY, not a method, and you count the negatives yourself. Writing
    `harmonic_frequencies -> n_imaginary()` is neither object's API and raises
    AttributeError.
    COMPLETE since #97: `PyFrequencyResult.normal_modes` is a real
    #[pyo3(get)] accessor on main (VERIFIED against origin/main
    2026-09-19), so both halves are reachable from Python.
C5. Barrier = E(TS) - E(reactant), with ZPE from the same frequency run.

C0-C5 VERIFIED reachable from PYTHON, end to end (2026-09-19)

The steps landed one at a time across several PRs, and the failure mode is a procedure that READS as complete while one step lives only in Rust. That happened twice: C3 until run_saddle was bound, and C4's mode vectors until #97 -- after which this document carried a stale "MODE VECTORS are Rust-only" caveat for a day.

So it is now asserted by execution rather than by reading:

C0/C1 QM region + link atoms     ferric.QmmmSystem
C2    optimize reactant/product  ferric.run_optimize_qmmm
C3    FIND the transition state  ferric.run_saddle
C4    verify                     ferric.run_frequencies
C5    WHICH minima does it join? ferric.run_irc          <- added 2026-09-19
C6    barrier                    IrcResult.forward_barrier() / reverse_barrier()

C3 -> C5 EXECUTED from Python (2026-09-19), NH3 umbrella inversion at
STO-3G. SCOPE: this runs `run_saddle` and `run_irc` only -- it does NOT
build a QmmmSystem (C0/C1), call run_optimize_qmmm (C2), or call
run_frequencies (C4). Those have their own coverage, so this is not a
C0-C5 chain test:

  saddle   converged, n_imaginary = 1, is_transition_state() = True
  IRC      -0.4257 / +0.4257 A pyramidalisation, both branches converged
  barrier  11.142 kcal/mol, symmetric to 3 decimals

The SIGN is the load-bearing check, not the energy. NH3's two pyramidal
minima are mirror images, so a walk that went the same way twice -- the
most likely direction bug -- gives the same energy with the SAME SIGN.
Only the sign test catches it, and it is MUTATION-VERIFIED through the
Python layer.

Pinned by crates/ferric-python/tests/test_saddle.py, and mutation-tested: renaming a checked attribute fails the test, so it is not a tautology over hasattr. It asserts REACHABILITY only -- each step has its own correctness tests; what this catches is a step quietly leaving the language tools/ is written in.

...and C3 now RUNS under embedding, not just reachable (2026-09-19)

Reachability was the weaker claim, and the QM/MM example made it weaker still: it demonstrated only a REFUSAL (H2 in an MM field has no saddle, so find_saddle declines). A refusal alone does not show the procedure works -- code that rejected everything would print the same thing.

crates/ferric-scf/tests/qmmm_saddle_converges.rs closes that. NH3 umbrella inversion under point-charge embedding converges in 5 steps to exactly one imaginary mode, is_transition_state() = true, z spread 0.0075 Bohr -- i.e. planar, the physically right answer. C3 is now demonstrated on the embedded surface, which is the surface a catalyst question is actually asked on.

The trap it surfaced, which belongs in C0. An MM field that BREAKS THE SYMMETRY DEFINING THE SADDLE removes the target rather than making the search harder: with an antisymmetric charge pair, max|g_z| at the planar geometry is 3.8e-3 against ~1e-16 in gas phase, so planar NH3 is not a stationary point at all. find_saddle correctly fails and it reads like a solver bug. See the transition-state cost section for the table and the diagnostic.

Every C-step RUN against merged main (2026-09-20)

Not one end-to-end run on one system -- two smoke tests on two systems, and the distinction matters because the sizes and the surfaces differ. Labelling this "C1-C5 end to end" would claim a continuity these runs do not have.

C1/C2/C4 -- ethane, QM = one CH3, covalent cut with a link atom, STO-3G. Embedded throughout, one MM field:

C1  QM 5 atoms ['C','H','H','H','H']; 3 MM charges; min link-charge 1.304 A
C2  optimize   converged=True  steps=4   E=-39.72650708            [0.1 s]
C4  freqs@min  9 modes, n_imag=0   counter=30 gradients, 31 SCF calls  [0.8 s]
C4  normal_modes reachable from Python: True

C4's counter=30 is n_gradient_evaluations; the BUDGET is 31 SCF calls (6N+1, the extra one undisplaced and uncounted). Both numbers appear here deliberately -- see the accounting section above for why quoting only the counter understates a Hessian by one.

C3/C5 -- planar NH3 inversion, STO-3G. A separate system, because the ethane methyl has no saddle to find:

C3  vacuum saddle   converged=True   n_imag=1  is_TS=True   E=-55.43766531
C3  in the MM field converged=False  n_imag=1  is_TS=False  (no stationary point)
C5  IRC             from the VACUUM saddle geometry and its imaginary mode,
                    run with point_charges= -- returns an IrcResult

The C3 pair is the point, and it is not a solver failure. The same search converges in vacuum and does not in the field: a symmetry-breaking MM field makes planar NH3 non-stationary, so there is no saddle left to find. Running the vacuum case is the discriminator -- without it, converged=False reads as a broken optimizer. See C0's symmetry warning.

The field IS being applied, and the honest way to show that is at ONE geometry with two Hamiltonians, since the field search has no stationary point to quote an energy from:

at the converged VACUUM saddle geometryenergy (Ha)
vacuum RHF-55.43766531
RHF + MM point charges-55.43664618
field shift+0.640 kcal/mol

(The shift is taken at ONE geometry. Subtracting the two saddle energies instead gives +0.635 kcal/mol, but that compares two DIFFERENT geometries, one of them not stationary, so it is not a field shift.)

The basis argument is a BasisSet for energies and a NAME for geometry changes. Checked across the entry points 2026-09-20:

takes BasisSet.bundled(...)takes the name "sto-3g"
run_rhf, run_dftrun_optimize, run_frequencies, run_saddle

Not arbitrary -- a call that MOVES the nuclei has to rebuild the basis at each new geometry, so it needs the name rather than a prepared set. But nothing in either signature says which it wants, and passing the wrong one is a TypeError about PyString conversion that reads like a bug in your code rather than a convention. It cost a run here.

Three more API details cost a run each here, all now fixed in the C-steps above: OptimizeResult.mol is a METHOD (o.mol()), FrequencyResult.frequencies is a PROPERTY (no parentheses), and n_imaginary is on SaddleResult, not on FrequencyResult. run_saddle is closed-shell only -- a doublet guess is refused with a clear message rather than silently solved.

These are smoke tests, not the regression net. The chain is pinned by test_the_whole_embedded_chain_runs_and_the_barrier_moves (crates/ferric-python/tests/test_saddle.py), which asserts the same split -- the vacuum converges, the field does not -- and uses irc.saddle_energy as a field-detector.

C1 and C4 VERIFIED to work (2026-09-18)

Run against the merged extension, so these are not claims:

C1 bare cut       : QM has 4 atoms            (ethane, QM = one CH3)
C1 with link atom : QM has 5 atoms  -> ADDED  symbols ['C','H','H','H','H']
C4 H2 at minimum  : 1 mode, n_imaginary = 0   frequencies [5018.8] cm-1

C1 (QmmmSystem(...).with_link_atoms([(0, 4)])) caps a cut C-C bond with an H, exactly as the catalyst path requires -- the cut across a covalent bond is the step that distinguishes a catalyst QM region from a ligand one. C4's n_imaginary returns 0 at a MINIMUM, which is the verifier's negative control: a verifier that cannot report "this is not a saddle" cannot report "this is" either.

So C0-C2 and C4-C5 are working code. C3 was the ONLY gap, established by grep (no dimer / NEB / P-RFO / eigenvector-following anywhere) rather than by this script -- a negative cannot be demonstrated by running something.

C3 CLOSED AND MERGED 2026-09-19 (ferric_scf::saddle, #106), and demonstrated under QM/MM embedding rather than only in the gas phase. The chain C0-C5 is complete. What that does and does not mean is in section 4. The IRC gap is closed (irc::follow_irc); what remains is the analytic Hessian.

API INCONSISTENCY worth knowing before writing a workflow: run_rhf takes a BasisSet OBJECT (ferric.BasisSet.bundled("sto-3g")), while run_frequencies takes a basis-name STRING. Passing the wrong one is a clean TypeError, not a silent failure, but it costs a round trip. The frequency entry point is run_frequencies, NOT harmonic_frequencies -- the latter is the Rust name and is not what pyo3 exports.

C4 now COMPLETABLE from Python (2026-09-19, PR #97)

FrequencyResult.normal_modes is bound, so a Python workflow can finally do what C4 requires -- count imaginary modes AND inspect what one displaces. Demonstrated on water/STO-3G:

3 modes, n_imaginary = 0  -> MINIMUM
softest mode (2049.4 cm-1) displacements:
    O0  0.0016
    H1  0.0125
    H2  0.0125

The bend moves both hydrogens symmetrically while the oxygen barely moves -- physically right, and the kind of check that distinguishes "one imaginary mode" from "one imaginary mode ALONG THE REACTION COORDINATE". A methyl rotor also gives exactly one imaginary frequency; only the vector tells them apart.

So the catalyst path's verification half is complete: C0-C2 and C4-C5 all work from Python. C3 -- FINDING the saddle -- remains the single blocker, and it is a missing capability (no dimer/NEB/P-RFO anywhere), not a missing binding.

API caveat, VERIFIED 2026-09-18: FrequencyResult.normal_modes is NOT exposed in the pyo3 bindings... SUPERSEDED 2026-09-19. #97 exposed it. PyFrequencyResult.normal_modes is a real #[pyo3(get)] accessor (crates/ferric-python/src/lib.rs:1768 on origin/main, re-verified 2026-09-19), returning Vec<Vec<f64>> in the documented (mode, 3N) layout. A Python workflow can now both COUNT imaginary modes and INSPECT them, so C4 is complete from Python.

If a local check disagrees, check the loaded extension before the source: the .so symlinked into .venv points at the MAIN checkout's target/release, so a worktree can be testing a stale build. That is what made this caveat look current when I first re-read it today.

C3 does not exist. SUPERSEDED 2026-09-19 by ferric_scf::saddle::find_saddle (P-RFO), and wired to QM/MM in examples/qmmm_saddle.rs. C0-C5 is complete in principle; section 4 states what that does and does not mean. A catalyst workflow no longer has to import its transition state from another code -- though doing so and using ferric to verify (C4) and compute the barrier at a better level (C5) remains a perfectly good option, and is the cheaper one when a TS is already in hand.

4. Catalyst optimization: the search gap is CLOSED, the cost one is not

2026-09-19. The blocker was C3 -- no saddle search anywhere in the tree. That is now implemented.

What landed

ferric_scf::saddle::find_saddle (MERGED, #106): partitioned rational function optimization. It partitions the Hessian eigenspace and solves a separate RFO step in each -- maximize along one followed mode, minimize in the orthogonal complement (Banerjee/Adams/Simons/ Shepard, JPC 89, 52 (1985)). Between steps the Hessian is carried by a Bofill update, chosen because it does NOT preserve positive definiteness; BFGS would drive out the negative eigenvalue the whole search depends on.

Why it could not be a flag on optimize.rs: that is a MINIMIZER, and its quasi-Newton update is kept positive definite on purpose. No step size turns a minimizer into a saddle finder.

Where the Hessian comes from

P-RFO does NOT use hessian.rs: hessian.rs::rhf_hessian is a documented stub that ALWAYS returns Err (see four paragraphs below). The working Hessian is frequencies.rs's central-differenced analytic gradient, and that is what saddle.rs calls.

The cost, which is now the binding constraint

The Hessian is 6N+1 gradient evaluations -- the counter reports 6N and omits the undisplaced call (MEASURED via the gradient counter: H2 = 12, water = 18 -- exactly 6N). For a 20-atom QM region that is 120 gradients for ONE Hessian. Rebuilding it every step is not a search, it is a Hessian benchmark, which is why hessian_recalc_every defaults to 0 (build once, then Bofill). A realistic catalyst run is therefore:

1 Hessian (6N+1 gradients) + ~20-60 P-RFO steps (1 gradient each)

so the Hessian dominates at small N and the steps dominate past roughly N = 10. That ratio, not the algorithm, is what sizes a catalyst job now.

What is still NOT available

  • No analytic Hessian. libint2 deriv_order=2 is absent from this build (VERIFIED from ~/.local/include/libint2/config.h: INCLUDE_ERI 1, INCLUDE_ONEBODY 1). Raising it means re-running libint's generation stage, a once-off out-of-band build, not a cmake flag. Every Hessian here is finite difference.

  • No IRC. CLOSED 2026-09-19. irc::follow_irc answers it. Note what it does NOT do: it stops on a gradient threshold and does not CONFIRM the endpoint is a minimum -- that needs a Hessian there, another 6N+1 gradients per side, and IrcBranch::converged reports which stopping condition fired so the caller can decide. P-RFO finds a first-order saddle; it does not prove which reaction it belongs to.

  • No reaction-coordinate constraint / relaxed scan. MoveMm freezes whole MM atoms and QM atoms are always free (free_atom_indices, qmmm.rs:1815), so there is still no constrained-scan route to a starting guess.

  • Not wired to QM/MM. DONE 2026-09-19, and it needed less than expected. crates/ferric-scf/examples/qmmm_saddle.rs runs the whole chain on an embedded system: SCF -> analytic embedded gradient -> finite-difference embedded Hessian -> projection -> saddle step.

    The part I expected to block it did not: a QM/MM Hessian needs no new machinery. frequencies::harmonic_frequencies already threads config.external_potential into the same rhf_gradient(.., ext) / ks_gradient_closed(.., ext) calls (frequencies.rs:649), so an embedded Hessian is one ordinary call.

    Three limitations remain, and they are why this ships as an EXAMPLE rather than a library entry point:

    • MM charges are FIXED (to_external_potential() evaluated once). A barrier computed this way omits MM relaxation along the reaction coordinate. optimize_qmmm rebuilds the field per step (qmmm.rs:2066) precisely because that matters when MM atoms are free.
    • Link atoms do not track the frontier as the QM region distorts. optimize_qmmm shares this.
    • Making it a library function means extracting optimize_qmmm's 172-line inline evaluator closure -- a refactor with its own risk, and its own PR.

    Reachable from Python since 2026-09-19: ferric.run_saddle(mol, basis, xc=, multiplicity=, max_steps=, trust_radius=, follow_mode=, delta=) -> SaddleResult, with is_transition_state() as a method so converged alone cannot be read as a TS. The refusal crosses the FFI boundary with its reason intact -- VERIFIED on H2 at 0.74 A, which returns "the projected Hessian at the starting geometry has NO negative eigenvalue (lowest = 9.612869e-1)" rather than an opaque failure.

  • Cartesian only. Internal-coordinate P-RFO converges in fewer steps on floppy systems.

  • The mode is not checked for being the RIGHT one. Exactly one imaginary frequency means first-order saddle, not "saddle for the reaction you meant" -- a methyl rotor gives one too. SaddleResult::imaginary_mode returns the vector so a caller can check; the module does not pretend to.

What it refuses to do, on purpose

  • Starting with no negative projected eigenvalue is a HARD ERROR naming the lowest eigenvalue. P-RFO from a minimum's basin has nothing to climb and would otherwise return a minimum labelled as a transition state.
  • is_transition_state() requires gradient convergence AND exactly one imaginary mode. Convergence alone is satisfied by every stationary point.

Honest status line

ferric can SEARCH for a transition state, CONFIRM one, and now FOLLOW the reaction path off it in both directions. The QM/MM wiring for the search exists and is demonstrated (NH3 inversion under point-charge embedding converges in 5 steps to exactly one imaginary mode). What remains missing is the ANALYTIC Hessian -- every Hessian here is finite-differenced from analytic gradients at 6N+1 evaluations. So a catalyst workflow knows what to do, and the remaining work is cost, not capability.


5. Ordered next actions

  1. Wire saddle::find_saddle to the QM/MM evaluator. DONE 2026-09-19 -- examples/qmmm_saddle.rs, verified running end to end on an embedded system. The catalyst workflow can now run. What remains is promoting it from an example to a library entry point (needs optimize_qmmm's evaluator extracted) and lifting the fixed-MM-field approximation; see section 4.

  2. Harvest the docked pose into context["geometry"] in run_funnel's stage loop (section 0). DONE (#93) -- funnel._harvest_geometry writes the key.

  3. Fix the "DFT + dispersion" label. DONE (#99) -- tier4_dft passes dispersion="d3bj" (native ferric-d3) by default, so the label matches what the tier computes. QM/MM dispersion is still absent (section on dispersion above).

  4. Decide the pose treatment (section 3) before any QM/MM adapter.

  5. Add the tier 3.5 producer: derive_pocket_charges -> context. Both quantum tiers ALREADY consume context["point_charges"], so this is a producer, not a new capability.

  6. Fix relax_pose_in_pocket's movable pocket: it builds its QmmmSystem from (q,x,y,z) with symbol "X" (pose_relaxation.py:188), but move_mm != "none" needs an MmTopology in the same atom order, and PocketCharges carries no element symbols. move_mm="within"/"all" type-checks and is unreachable in practice.

  7. Fix the drifted docstring: qmmm.rs:26-34 says "no Lennard-Jones QM-MM term", but qmmm_mm_terms (qmmm.rs:1578) computes one at qmmm.rs:1674-1704. The code is right; the comment is stale.

  8. Expose FrequencyResult.normal_modes to Python. [DONE, #97: FrequencyResult.normal_modes is in the bindings, which is what lets a Python caller check find_saddle's imaginary mode points along the reaction coordinate, step C4's second half.] Without it a Python workflow can count imaginary modes but not check one points along the reaction coordinate, so it cannot complete TS verification (step C4). Small, self-contained, and a prerequisite for any Python-driven catalyst work.

  9. Seed each finite-difference displacement from the undisplaced converged density. VERIFIED that frequencies.rs has no restart machinery, so all 6N displaced SCFs start cold from the default guess despite being a delta-Bohr perturbation apart. Self-contained, and it pays off on every frequency run -- which is every TS verification.

  10. Surface tools/ in site/src/SUMMARY.md. [DONE: the pipeline is published under "End-to-end applications", "Toxicity screening" and the "Project notebooks" section of SUMMARY.md.]

Proposing viable substitutions for a drug active site — resolved on danuglipron

Prototyped 2026-09-19 against the real parent (DANUGLIPRON_SMILES, PubChem CID 134611040) and the real receptor (7LCJ, GLP-1R). Every number below was RUN, not estimated.

The answer: ~90% exists, and the missing piece is not what I expected

I expected the gap to be a connector between enumeration and pocket scoring. Its enumeration half now exists: propose_substitutions and embed_proposals in tools/pipeline/substitution.py turn a parent SMILES and a site into proposals with 3-D geometries (Å). Placing a proposal in the pocket still needs docking; see "Order of work" below. But prototyping showed a more basic problem first: both cheap gates are useless on this target, for different reasons.

What ran

S1 enumerate      : 54 analogues   (9 aromatic CH sites x 6 substituents)
S2 pharmacophore  : 54 kept, 0 rejected
S3 liability      :  0 clean, 54 flagged

S2 rejects nothing — and that is CORRECT, not inert

GLP1R_PHARMACOPHORE.check() passed all 54. Before concluding the filter works, I tested whether it CAN reject:

caseall satisfiedbroken features
PARENT (control)True—
acid -> methyl esterFalseacid_or_bioisostere
benzeneFalseall four
ethanolFalseall four

So the gate is reachable and discriminating. 0/54 is a true negative: substituting an aromatic CH cannot break an acid, a fused diazole, a basic amine or a nitrile terminus. The pharmacophore gate belongs on SCAFFOLD moves (bioisostere_swaps, ring_contractions), where those features are at risk — not on a substituent scan.

S3 rejects everything — because the PARENT already violates it

PARENT   MW 555.6   cLogP 4.89   TPSA 113.5   violations: ['MW 556 > 500']

Danuglipron is a Phase-2 clinical compound that breaks Lipinski on MW. Every substitution inherits that violation, so an absolute rule-of-5 gate rejects 54/54 and ranks nothing. The rule of 5 is a hit-finding filter; applied to an optimized clinical molecule it is a constant, not a discriminator.

The gate that works: RELATIVE to the parent

Same 54 analogues, scored as the CHANGE each substitution makes:

substdMWdcLogPdTPSAverdict
CN+25.0-0.13+23.8score it
OMe+30.0+0.01+9.2marginal
F+18.0+0.140.0deprioritize
Me+14.0+0.310.0deprioritize
Cl+34.4+0.650.0deprioritize
CF3+68.0+1.020.0deprioritize

Chemically sensible: nitrile is the only substituent that LOWERS lipophilicity, and CF3 is the worst offender — against a parent already at cLogP 4.89. That is a ranked, actionable answer where the absolute gates gave none.

This generalises: for lead OPTIMIZATION, every cheap descriptor gate must be relative. The same logic is why the QM tier must report ddE against the parent rather than an absolute binding energy — error cancellation and gate discrimination are the same argument applied at different cost tiers.

The pipeline

S0  Site selection   SMARTS chosen from the POCKET CONTACT MAP, not "every
                     aromatic CH". 9 sites x 6 substituents = 54 is already
                     more than the QM tier can afford; site choice is the real
                     budget control and it is human judgment.
S1  Enumerate        substituent_scan (+ bioisostere_swaps for scaffold moves)   ms
S2  Pharmacophore    GLP1R_PHARMACOPHORE.check -- gate SCAFFOLD moves only       ms
S3  Liability        RELATIVE to parent, never absolute                          ms
S4  Dock             into 7LCJ; harvest the pose (see PR #93)              26.4 s/lig
S5  Prescreen        batch_prescreen -- classical pocket field, no SCF        ms/pose
S6  xtb in field     context["point_charges"]                            0.05-0.152 s
S7  QM/MM ddE        compute_binding_energy on parent AND analogue              minutes

Blockers, in order

  1. context["geometry"] is never written (PR #93 fixes it). Until then S4's pose is discarded and S6/S7 score a gas-phase conformer.
  2. No dispersion correction in ferric. For a halogen or CF3 scan this is the dominant attractive term. Three places label tier 4 "DFT + dispersion" and it is not computed. Disqualifying for exactly the substituents this scan enumerates.
  3. Pose noise swamps the signal. RESULTS.md MEASURED per-pose sd 29.07 kcal/mol; substituent effects are 1-2 kcal/mol. One pose per analogue reports noise. funnel.py keys one row per MOLECULE and cannot express an ensemble — decide the pose treatment BEFORE writing the adapter.
  4. No connector between tools.isomers and tools.active_site (verified by grep, both directions).

The connector, specified against the REAL signatures (2026-09-19)

PR #96 landed the enumeration + relative-scoring half (tools/pipeline/substitution.py). What remains is carrying a proposal into the pocket. The interfaces it must bridge, read from source rather than assumed:

tools/pipeline/substitution.py
    propose_substitutions(parent_smiles, substituents, site_smarts,
                          require_smarts) -> list[SubstitutionProposal]
        SubstitutionProposal{ smiles, label, is_parent, d_mw, d_clogp, d_tpsa }

tools/active_site/ligand_embedding.py:91
    embed_ligand_from_coords(symbols, coords_angstrom, pocket=None,
                             basis="def2-svp", charge=0, multiplicity=1,
                             overlap_cutoff_angstrom=1.5) -> EmbeddedLigand

tools/active_site/prescreen.py:128
    batch_prescreen(pocket, ligand_xyz_paths, charge_source,
                    basis="def2-svp", overlap_cutoff_angstrom=1.5)

tools/active_site/binding_energy.py:64
    compute_binding_energy(ligand_xyz, pocket_pdb, basis="def2-svp",
                           method="rhf", xc=None, ff="AMBER",
                           min_available_gb=2.0) -> BindingEnergyResult

embed_ligand_from_coords is the seam. It takes in-memory symbols and coordinates, carries explicit charge/multiplicity, and does the pocket-overlap filtering itself -- so the connector needs no temp files and no second embedding path.

The connector, and the step still missing

SubstitutionProposal carries SMILES; embed_ligand_from_coords needs 3-D coordinates in the pocket's frame. So the connector is:

proposal.smiles --(ETKDG, seeded: embed_proposals)--> symbols + coords
                --(docking: dock_ligand)--> pose in the receptor frame
                --> embed_ligand_from_coords(..., pocket=pocket)
                --> batch_prescreen / compute_binding_energy
                --> report ddE against the PARENT proposal

The ETKDG hop exists and is tested: embed_proposals delegates to tools.structure.from_smiles (seeded ETKDG + MMFF) and returns (symbols, coords) in Angstrom. Those coordinates are centred on the origin, 226 A from the 7LCJ pocket, so they cannot go into the pocket directly. What is missing is the placement: docking each proposal (dock_ligand returns DockedPose.coords_angstrom in the receptor frame, and funnel._harvest_geometry carries it into context["geometry"]) and the pocket-side tier callables that consume that pose.

VERIFIED on the REAL GLP-1R pocket (2026-09-19)

The full chain, run against testdata/molecules/c9_systems/danuglipron/7LCJ_pocket.pdb:

pocket derived : 6458 point charges in 2.7 s (pdb2pqr30 3.7.1)
proposals      : embedded via embed_proposals
ligand         : 15 QM atoms handed to embed_ligand_from_coords

SMILES -> 3-D -> real pocket -> QM-ready. Every hop is code that exists today.

The first version of this check was VACUOUS, and the tell was a suspiciously round number. It reported "6458 charges, 0 filtered" -- which looked like a clean pass but meant the overlap filter had removed nothing. The pocket spans x = 185.8..284.8 Bohr; my probe ligand sat near the ORIGIN, ~200 Bohr away, so there was nothing to filter. Re-run with the ligand translated to the pocket centroid (124.3, 148.2, 116.9 A):

ligand positioncharges keptfiltered
origin (outside the pocket)6458 / 64580
pocket centroid6449 / 64589

Both rows together are the measurement: the filter fires when the ligand is inside and not when it is outside. Either row alone proves nothing.

Same failure mode as the x=0.0 fixture in the structure readers -- a geometry that cannot exercise the thing under test produces a green that means nothing.

The prescreen tier RUNS -- and its first result changes the pipeline design

Full chain executed: propose -> embed -> real 7LCJ pocket -> prescreen_pose with cheap Gasteiger charges (no QM). 13 poses scored. It works.

The ranking it produced is the finding:

substituentn sitesmean (kcal/mol)spread across sites
CN3-4.7711.81
F3-4.3210.26
CF33-2.2116.49
Cl3-1.979.71
parent1+5.46--
BETWEEN substituents (full range):  17.30 kcal/mol
WITHIN one substituent (same group, different ring position): 16.49
ratio: 0.95

WHERE you put the group matters as much as WHICH group it is. The same Cl lands at ranks 5, 6 and 11 depending on ring position. A pipeline that reports one number per SUBSTITUENT -- which is what the cheap descriptor gate does, and what funnel.py is shaped for (one row per iso.canonical) -- is averaging over a variable as large as the one it is trying to measure.

This is the same shape as the pose-noise problem below, arriving one tier earlier and for a different reason: there it is conformational, here it is positional, and the positional one is NOT fixable by ensembles because the sites are genuinely different molecules.

Design consequence: the unit of the pipeline must be the (substituent, SITE) pair, not the substituent. propose_substitutions already enumerates per site and SubstitutionProposal already carries the product SMILES, so the data is there -- what must change is any downstream aggregation that collapses to a per-label mean. The agg in the earlier descriptor demo does exactly that and was fine ONLY because dMW/dcLogP are site-independent by construction. Nothing downstream of the pocket is.

CAVEAT on scope: benzoic acid at the pocket centroid, Gasteiger charges, no docking. This measures the METHOD's sensitivity to placement, not a real affinity ranking -- a docked pose would place each analogue properly rather than translating a fixed conformer. The 0.95 ratio is the transferable part.

Two constraints the connector must respect, or it reports noise

  1. ddE, never absolute. The parent proposal is already in the output for exactly this reason. An absolute binding energy carries the full method error; the difference cancels most of it. Same argument as the relative descriptor gate.

  2. The pose problem is RESOLVED as a design decision, 2026-09-19 (M12). All three routes to averaging the scatter away are now closed with numbers: more poses (M5, sd flat in n), relax in field (M6, real 15%, ~3 orders short), and real docking (M12, 1%, 32.5x short). Docked poses score at sd 28.75 vs M6's 29.07 while being geometrically MORE diverse (pairwise RMSD 3.84 -> 5.81 A), so the scatter is not an ensemble-quality problem that better poses fix.

    The sharpest number: Vina's own score on those same 15 geometries has sd 0.83, xtb on the identical geometries 28.75. The cheap tier sees a nearly flat landscape where xtb sees 103 kcal/mol.

    The connector must AVERAGE over poses, not select one. Measured on the same 15 poses: Vina's ranking showed NO DETECTABLE relation to the xtb score (Spearman -0.261, p=0.35 -- non-significant, i.e. no correlation demonstrated, not independence proven), so picking rank 0 behaves as one draw from an sd-28.75 distribution. ddE noise is 40.66 selected vs 4.07 averaged at n=100 -- selection is 10x WORSE. M9's 0.95 A redock licenses "the near-native pose is in the set", not "it is first"; M9 itself says Vina got it first "partly by luck" (r = +0.461, 4/20 under 2.0 A).

    So averaging stands as the least-bad estimator, and funnel.py's one-row-per-MOLECULE keying IS still a blocker. All four pose protocols are now closed; the remaining lever is a scoring metric less pose-sensitive than a point-charge interaction energy.

    The pose treatment is decided (average across poses); the QM tier stays blocked until the funnel can carry an ensemble. MEASURED per-pose sd is 29.07 kcal/mol (RESULTS.md M5/M6) against substituent effects of 1-2 kcal/mol, so one pose per analogue reports noise, and funnel.py keys one row per MOLECULE (funnel.py:162), so it cannot express an ensemble. Do not wire the QM tier until ensemble support exists -- the prescreen tier is cheap enough to run per-pose and is the right place to start.

Order of work

  1. Pocket placement: dock each embedded proposal (embed_proposals already gives the (symbols, coords) from its SMILES).
  2. Connector to batch_prescreen -- CHEAP (classical field, no SCF), so it can afford an ensemble and sidesteps constraint 2 entirely.
  3. QM tier (compute_binding_energy, ddE) only after the funnel can carry a pose ensemble (the treatment, averaging, is decided). Dispersion is available: D3(BJ) (#99, merged), without which a halogen/CF3 scan would miss its dominant attractive term.

What to build

tools/pipeline/substitution.py — an adapter, not new chemistry: parent SMILES

  • site SMARTS + pocket PDB in, funnel-shaped tier callables out, always reporting ddE against the parent. It holds the enumeration half today (propose_substitutions, embed_proposals); the pocket-side tier callables are still to be written.

The anchor test is in place (test_substitution.py): an empty substituent set must return exactly the parent.

How we make a pipeline for proposing viable active-site substitutions

2026-09-19. The pipeline's STAGES are settled and every one of them exists in code. What is NOT settled is that its ranking can be trusted at the 1-2 kcal/mol resolution a substitution campaign needs. All four pose protocols have been measured and all four fail.

Everything here is MEASURED on danuglipron against the GLP-1R pocket (7LCJ) unless labelled otherwise. Sources: experiments/danuglipron/RESULTS.md (M4-M13), wiki/golden-path-pipeline-2026-09-18.md.


Average over poses; do not select one

Do not "pick the top-docked pose". It is measurably worse than averaging, and the reason generalises: nothing in this sample shows Vina's ranking axis tracks the xtb scoring axis, so selecting on it behaves like a random draw.

The pipeline's STAGES are unaffected -- P1-P7 stand -- but what the output may be used for is not.

Selecting the top-docked pose is 10x worse than averaging. Vina's ranking showed no detectable monotonic relation to the xtb score on these 15 poses (Spearman -0.261, p=0.35 -- a NON-SIGNIFICANT test, which fails to demonstrate a correlation rather than proving there is none; n=15 can only detect |rho| >= ~0.51). With no usable ranking signal, "pick rank 0" behaves as a single draw from a distribution with sd 28.75 kcal/mol. Averaging at n=100 gives ddE noise 4.07; selecting gives 40.66. Both miss the 0.25 kcal/mol gap, by 16x and 163x.

I had justified selection with "M9 redocks to 0.95 A". M9 says two paragraphs under its own headline that it did so "partly by luck" (r = +0.461, only 4/20 poses under 2.0 A). I quoted the headline and not the caveat beneath it.

What this means for the pipeline: the stages below are right, and the ranking they produce is NOT trustworthy at 1-2 kcal/mol resolution by any pose protocol currently available. Use it to answer "does this analogue bind in this site at all", not "which of these two is better". See "What the pipeline may and may not claim" at the end.

The decision that WAS thought to be blocking: select vs average (SUPERSEDED)

Per-pose energy scatter is sd ~29 kcal/mol against substituent effects of 1-2 kcal/mol. Three routes to averaging that away have now been tried, and all three are closed with numbers:

routeresultverdict
more poses (M4/M5)sd flat in n; SEM falls as 1/sqrt(n) but sd does not moveclosed -- resolving a 0.25 kcal/mol gap needs ~7350 poses
relax poses in field (M6)34.23 -> 29.07, a real 15% tightening, poses stay distinctclosed -- ~3 orders of magnitude short
real pose search (M12)29.07 -> 28.75, 1%, poses MORE diverse (RMSD 3.84 -> 5.81 A)closed -- 32.5x short

So the answer is not a better ensemble. It is also not selection -- M13 measured that and it is worse (see the retraction above). A fourth row belongs in that table:

| select one pose (M13) | ddE noise 40.66 vs averaging's 4.07 | closed -- 10x WORSE |

A fifth row, and it is a different KIND of row (M17, 2026-09-19)

Every route above attacks the per-pose sd and accepts the estimator. All of them compute ddE over INDEPENDENTLY embedded ensembles, which is an UNPAIRED design over noise that is largely COMMON to the two molecules: the scatter is pose-conformational, a property of the scaffold in the pocket, while a substitution changes a handful of atoms and leaves ~68 where they were.

| pair poses by scaffold (M17) | paired SEM 0.221-0.615, vs 0.459-0.742 unpaired ON THE SAME 24 POSES | PROVISIONAL -- gas-phase MMFF only |

The comparison in that row is same-data, and that matters. The 4.07 above is the n=100 UNPAIRED SEM from a different experiment; quoting 4.07 -> 0.221 as the effect of pairing would credit pairing with the difference between two experiments. paired_ddE computes both estimators from the SAME 24 poses, and the honest isolated gain is 1.21x (Cl) to 2.42x (F) on the SEM -- with the self-anchor and N-methyl far higher because their noise is almost entirely common. What crosses the 1-2 kcal/mol line is the ABSOLUTE paired SEM, which is a cross-experiment comparison and is labelled as one.

var(ddE_paired) = sd_A^2 + sd_B^2 - 2*rho*sd_A*sd_B, which is the familiar 2*sd^2*(1-rho) only when the two spreads are EQUAL (they are here, to a few percent). So the win is essentially all in rho, and rho is what a pocket could destroy -- when the spreads differ the scale terms move the variance too, and the ratio stops being a function of rho alone. Note that a shared random SEED is not a pairing: ETKDG with the same seed on two different graphs gives uncorrelated conformers. The pairing must be geometric -- pose k of the analogue BUILT FROM pose k of the parent.

Two results from that probe matter more than the SEM:

  • A hard scaffold pin FAILS its own exactness anchor by +13.8 kcal/mol -- it charges that much for pairing the parent with ITSELF, because the substituent is forced into whatever room the parent pose left. Relaxing the substituent against a restrained scaffold passes at +0.004.
  • The Cl row is the warning: rho 0.399, SEM ratio only 1.21x (a standard-error ratio, not a variance one -- the variance figure is its square). Pairing helps where the substitution is LOCAL and degrades smoothly to the unpaired case where it is not -- so a paired floor is PER-CANDIDATE, never one campaign-wide number.

This does NOT reopen the ranking claim below. It is measured on one molecule, one force field, gas phase, with no pocket, and the thing a pocket most plausibly breaks is exactly the correlation the method depends on. It is a reason to run that experiment. See RESULTS.md M17 and wiki/paired-ddE-substitution-2026-09-19.md.

Pose GENERATION is nonetheless solved for this target: M9 redocks danuglipron into 7LCJ at 0.95 A, 20/20 poses within 5 A of the known site, where the best of 20 RDKit conformers was 2.23 A. That licenses "the near-native pose is in the candidate set". It does not license "the first one is it" -- M9's own r(vina_score, RMSD) = +0.461 says otherwise.

The number that makes the decision concrete

On the SAME 15 docked geometries:

Vina's own score      sd =  0.83 kcal/mol
xtb (pose_fit)        sd = 28.75 kcal/mol

The cheap tier sees a nearly flat landscape where xtb sees one spanning 103 kcal/mol. This is M9's r(vina_score, RMSD) = +0.461 viewed from the energy side, and it is the empirical case for the whole hierarchy: tier 1 generates the right answer among its candidates and cannot pick it out. If it could, tiers 2-4 would be decoration.


The pipeline

P0  parent + site SMARTS + pocket PDB
P1  ENUMERATE      propose_substitutions          -> SMILES per (substituent, SITE)
P2  DESCRIPTOR     relative_descriptors           -> gate vs the PARENT, not absolutes
P3  EMBED          embed_proposals (ETKDG, seeded) -> symbols + coords
P4  DOCK           dock_ligand per analogue        -> pose ENSEMBLE (do NOT take rank 0)
P5  PRESCREEN      prescreen_pose (classical)      -> cheap triage, no SCF
P6  RANK           xtb pose_fit, MEAN over the ensemble -> ddE vs the parent
P7  CONFIRM        ferric DFT + D3(BJ)             -> survivors only

Steps P1-P3 and P5 exist and are tested (#96, #102). P4 exists (tools/docking/vina_dock.py) and is validated by M9's redock. P6's engine exists (tools/campaign/xtb_engine.py). P7 uses #99's D3(BJ) (merged), without which a halogen/CF3 scan would miss its dominant attractive term.

The four rules the pipeline must follow, each from a measurement

  1. ddE, never absolute. The parent is carried through every stage for this reason. An absolute binding energy carries the full method error; the difference cancels most of it. Same argument as P2's relative gate.

  2. The unit is the (substituent, SITE) pair -- and NEITHER TIER CAN EXPRESS IT. MEASURED within/between ratio 0.94-0.95 on two independent constructions: WHERE a group goes matters as much as WHICH group. But:

    • the CHEAP gate is site-blind by construction (M15). MW, cLogP and TPSA are whole-molecule sums, so 9 sites of one substituent give ONE descriptor tuple. Verified on ortho/meta/para fluorobenzoic acid: bit identical. No fix to relative_descriptors changes this.
    • the EXPENSIVE tier is noise-limited (M4-M14): ddE noise 4.07 kcal/mol against effects of 1-2.

    So the substituent axis is answerable and the site axis is not. Rank SUBSTITUENTS cheaply; treat placement as a question for chemistry knowledge or an experiment, not for this pipeline. Seeing a site would need a POSITIONAL descriptor (3-D shape, per-atom charge, a QM property at the site) -- an addition to cost, not a fix to apply.

  3. One row per molecule is the WRONG shape. Averaging is the least-bad estimator (see above), so funnel.py needs to express a pose ENSEMBLE -- and it keys one row per candidate (funnel.py:162). This is a real, open gap.

  4. Ionization state is part of the measurement. Danuglipron's carboxylic acid is deprotonated at pH 7.4; the anion/neutral split is 143 kcal/mol (M-series). Net charge is an explicit input at every scoring stage, never defaulted to 0.

Cost, from the measured hierarchy

tiermethods/posepopulationjob
1AutoDock Vina1e-51e5-1e6search pose space
2MMFF941e-31e2-1e3relax, declash
3GFN2-xTB5e-110-1e2rank survivors
4ferric DFT + D3(BJ)6e+21-10final energetics

Tier 4 runs end to end inside the funnel (test_golden_path_smoke.py, see below), so its COMPOSITION is validated. No tier-4 number has been checked against an external reference in this pipeline, so do not present tier-4 energies as validated binding energetics.


What the pipeline may and may not claim

May: "this analogue docks into this site, and here is where." Pose GENERATION is validated (M9: 0.95 A redock, 20/20 within 5 A). The enumeration, the relative descriptor gate and the classical prescreen all work and are tested.

Not yet, but no longer ruled out (M17): a per-candidate ddE at 1-2 kcal/mol, via a PAIRED estimator rather than a quieter ensemble. Gas-phase MMFF gives SEM 0.221-0.615, against 0.459-0.742 unpaired on the SAME poses (the n=100 4.07 is a different experiment). Untested in a pocket, which is the test that decides it.

May not, part 1: "put this group at THIS position." The cheap gate cannot see position at all (M15, inherent), and the expensive tier cannot resolve the difference. Both halves of the stated unit are blocked, for different reasons.

May not, part 2: "analogue A binds better than analogue B by 1-2 kcal/mol." No pose protocol available today supports that. The five measured options:

protocolddE noise (kcal/mol)vs the 0.25 gap
select one pose (M13)40.66163x
average n = 100 (M5)4.0716x
a different scorer (M14)4.68 (best tracking)19x
relax then average (M6)~4.1~16x
dock then average (M12)~4.1~16x

Averaging is the least-bad option and is still 16x short. Reporting a ranking from this would be reporting noise.

What is still NOT settled

  • The lever left is a scorer less sensitive to pose. CLOSED by M14 (2026-09-19). Scored the SAME 19 docked poses with every scorer in the repo, comparing coefficient of variation (dimensionless, so comparable across scales):

    scorerCVSpearman vs pose_fit
    vina_score0.071-0.202 (p=0.41)
    pose_fit (xtb)0.315reference
    prescreen (classical)2.955+0.353 (p=0.14)

    prescreen is 9.4x worse. Vina looks 4.4x smoother and does not track pose_fit at all -- smooth because it is insensitive, not because it is better. No scorer in this repo is less pose-sensitive, so the remaining route is one that is pose-averaged BY CONSTRUCTION (FEP, or an ML affinity model trained on ensembles), which is outside this campaign.

  • funnel.py cannot express an ensemble (funnel.py:162, one row per candidate). Whether that is worth fixing depends on the resolution you want, and the answer is measured (sd = 33.06 from M14's 19 docked poses):

    poses averagedddE noisevs the 0.25 kcal/mol gap
    146.75187x
    1512.0748x
    1004.6819x
    10001.486x

    A 2-sigma resolution of 0.25 kcal/mol needs ~140,000 poses per candidate.

    So ensemble support is NOT the blocker it was listed as for lead optimisation -- no reachable n gets there. It IS worth building if the question is coarse: separating a 15 kcal/mol control from the parent needs only a handful of poses, and that is the gate the campaign actually failed at n=1 (v1's selection-bias artifact). Build it for gates, not for rankings, and size n from this table rather than from intuition.

  • Tier 4 unvalidated -- M10 recorded it does not fit. STALE, and it cites a title M10 itself retracted on 2026-09-02. Checked against the code 2026-09-19:

    • Cost: RESOLVED. 612.4 s (10.2 min), 18 iterations, converged, for the 71-atom neutral acid at STO-3G/PBE. The ">57 min, did not finish" was MEMORY CONTENTION -- a 7.26 GB auto-budget against a ~9.5 GB need, paging until the OOM killer fired -- not DFT cost.

    • The 0-of-5 survivor failure: both causes FIXED and verified present. (a) the driver declared net_charge=-1 on NEUTRAL structures, which asks for an electron that does not exist; now deprotonates the STRUCTURE (Isomer.deprotonated, tools/isomers/model.py:44) with test_deprotonation_conserves_electron_count pinning it. (b) ring contractions that SEVER a ring produced fragment pairs; now rejected at enumeration (enumerate.py:82) and again at tier 1 (tiers.py:125).

    • End to end since the fixes: DONE 2026-09-19. The full FF -> xtb -> DFT stack now runs in tools/pipeline/tests/test_golden_path_smoke.py:

      FORCE_FIELD    3 -> 3   failed 0
      SEMIEMPIRICAL  3 -> 2   failed 0
      QUANTUM        2 -> 1   failed 0     <- used to be 0 out
      survivor: CC(=O)O  dft = -225.757 Ha
      

      ~1.7 s, so it belongs in the fast tier, and it is mutation-tested against the exact M10 failure mode (making tier 4 fail every candidate fails the test).

    So tier 4 is validated as far as COMPOSITION goes. What is genuinely left: no tier-4 number has been checked against an external reference IN THIS PIPELINE (ferric's DFT is separately validated against PySCF to ~2e-8 Ha, but that is the solver, not the funnel's use of it).

  • No ranking validated end to end. VERIFIED 2026-09-19 that the repo contains no experimental affinity data at all (grepped experiments/ and testdata/ for IC50/Ki/Kd/pChEMBL: zero hits), so this is blocked on DATA ACQUISITION, not on analysis. No further measurement inside this campaign can move it, which is why the question "how do we make this pipeline" is answered and "is its ranking right" is not.

    What it would take: a congeneric series with measured relative affinities on this target, ~10+ compounds spanning >2 kcal/mol. Until then the pipeline's output is a HYPOTHESIS GENERATOR, and the measured noise floors above say how far to trust it.

Honest status line

The pipeline's SHAPE is settled and every stage exists: enumerate relative to the parent, dock, prescreen, score, report ddE per (substituent, site). Its RANKING is not trustworthy at the resolution a substitution campaign needs, and four pose protocols have now been measured to establish that rather than assumed. Use it to triage what binds; do not use it to order candidates.