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 are | Start with |
|---|---|
| A chemist who wants numbers | Your first calculation, then Choosing a method |
| Coming from PySCF | For PySCF users |
| Working on drug-discovery workflows | End-to-end applications and QM/MM |
| A method developer | Electronic response, Architecture, Rust API |
| An automated agent | For 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 to | Do this | Time |
|---|---|---|
| Run calculations | Install the prebuilt wheel | about a minute |
| Change ferric's Rust code, or use MPI | Build from source | about 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
ferriccommand onPATHthat runs TOML input files (the same CLI ascargo 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.shdownloads it (checksum pinned) and needscurl,python3,zstdandpatchelf - 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 catchesdebug_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):
| Integrals | Source build with scripts/install-libint.sh (conda-forge 2.13.1) | PyPI wheel (ferric's libint2 2.7.2 export) | Needed for |
|---|---|---|---|
| 4-centre ERI | 7 / 6 / 3 | 6 / 6 / 3 | SCF, gradients, analytic Hessians |
| One-electron (overlap, kinetic, nuclear) | 7 / 6 / 3 | 6 / 4 / 3 | the same |
| 3- and 2-centre ERI | 7 / 7 / 4 | 6 / 6 / – | RI-MP2, RPA, GW and their gradients |
| G12 geminal | 4 / – / – | – | 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. Whenrun_rhfruns out of iterations it still returns an energy; it sets the flag and does not raise. A number withconverged == Falseis 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 to | Go to |
|---|---|
| Pick a method for a chemistry question | Choosing a method |
| See what every method supports (open shell? gradients? CLI?) | Capabilities and validation |
| Charged or open-shell molecules, geometry optimization, SMILES input | Recipes |
| The whole Python surface | Python bindings |
| Coming from PySCF | For PySCF users |
| Know what to trust | Capabilities 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_THREADS | unset | 1 | 6 | 12 |
|---|---|---|---|---|
RI-JK RHF (benzene-rhf-def2-rijk.toml) | 54 s | 31 s | 9.7 s | timed out at 1800 s |
PDEP-RPA (benzene-pdep-rpa.toml) | 5.9 s | 7.2 s | 22.9 s | 30.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 withoutdensity_fit()), passdf_j_aux="exact"torun_dftorrun_ksdft, anddf_k_aux="exact"too for a hybrid."","none","off"and"conventional"mean the same. In the CLI, set[scf] df_j_aux = ""anddf_k_aux = "". The CLI'sSCF J/Klog line then readsRI-JK viawith a blank name; the run uses exact J and K. - Or fit on both sides:
density_fit(auxbasis="def2-universal-jkfit")in PySCF, orrun_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_rhftakes a basis name or""here, not"exact". run_qmmm(KS methods),run_gwandrun_u_gwwithxc,run_tddftwith a functional,run_tdhf_static_polarizability,run_double_hybridandrun_rs_mp2_rpaalways density-fit their reference SCF withdef2-universal-jkfitand have no opt-out.
Units differ by interface and by accessor
| Quantity | Where | Unit |
|---|---|---|
Molecule geometry input (from_xyz, from_xyz_string, .xyz files) | Python, CLI | Ångström |
| Coordinates inside the Rust library | Rust | Bohr |
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_rpa | CLI TOML, Python | Å⁻¹ |
Double-hybrid ω: [dft] omega (wb97x-l-v) | CLI TOML | Bohr⁻¹ |
tune_omega (bracket and result); omega= of compute_eri3_mo and compute_metric_2c | Python | Bohr⁻¹ |
| Range-separation ω | Rust configs | Bohr⁻¹ |
QmmmSystem(...) coordinates | Python | Ångström |
QmmmSystem.point_charges() | Python | Bohr |
QmmmSystem.link_atom_positions() | Python | Ångström |
| Energies | everywhere | Hartree |
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:
| Capability | Python | What the CLI has instead |
|---|---|---|
| Transition-state search, IRC | run_saddle, run_irc | no task for either |
| QM/MM MM forces, full-system gradient and optimization, smeared charges, Thole polarization, an MM force field | run_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 couplings | run_cdft, CdftConstraint, cdft_coupling | nothing |
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(/tmpby 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.001completes the SCF, then exits withRI-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:
| Recipe | Needs a clone? | Extra dependencies |
|---|---|---|
| 0. SMILES → energy | yes (tools.structure) | RDKit |
| 1. Single point | only for examples/water-rhf.toml; your own .xyz + TOML works anywhere | — |
| 2. Ions and radicals | no | — |
| 3. Optimize | only for examples/h2-lda-opt.toml | — |
| 4. Ligand funnel | yes (tools.pipeline) | RDKit, xtb on PATH |
| 5. Residue ranking | yes (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_smilesreturns 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_energyise_scf + e_dispersion.dispersion=None(the default) leavese_dispersionasNone, meaning "not evaluated", never0.0.run_dftdensity-fits the Coulomb term by default. That matters when you compare against a code using exact Coulomb (recipe 6).from_smilesreads the charge from the SMILES. Spin is never inferred: passmultiplicity=2for 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_dftdocstring). - 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_chargesneedspdb2pqrand 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:
- Compute the field each residue produces at the reactive center.
- Rank residues by their contribution. That's your candidate list.
- Check the top few with QM/MM (QM/MM; the embedding matches
pyscf.qmmm.mm_chargeto <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
| Task | Recommended | Where | Grade | Scaling | Example |
|---|---|---|---|---|---|
| Geometry optimization | KS-DFT (e.g. PBE, B3LYP) or RHF, task = "optimize" | CLI, Python (run_optimize is RHF) | Proven | N⁴ | h2-lda-opt.toml, h2_opt.toml |
| Harmonic frequencies | KS-DFT or RHF/UHF/ROHF, task = "frequencies" (finite differences of the analytic gradient, 6N gradients) | CLI, Python run_frequencies | energies Proven; check the printed Hessian asymmetry | 6N × N⁴ | water-frequencies.toml |
| Transition state | run_saddle (P-RFO), then run_irc to confirm which minima it connects | Python only, closed shell | see Capabilities and validation | ~2(6N+1) gradients + steps | — |
| Conformer or reaction energies, routine | KS-DFT + D3(BJ) | CLI ksdft + [dft] dispersion = "d3bj", Python run_dft(dispersion=...) | DFT Proven; D3(BJ) matches simple-dftd3 to <1e-12 Ha | N⁴ (DFT) | water-pbe-d3bj.toml |
| Correlated energies, small to medium | RI-MP2 | CLI rimp2, Python run_rimp2 | Proven | N⁵ | water-rimp2.toml |
| Correlated energies, benchmark quality, small | CCSD(T) (closed shell) | Python run_ccsd_t only; CCSD also CLI ccsd | CCSD Proven; (T) matches PySCF ~1e-6 Ha on H2O/cc-pVDZ | N⁶ / N⁷ | water-ccsd.toml (H2) |
| Non-covalent interaction energies | Attenuated 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, ksdft | att-MP2 and 2terfc Proven (as implementations); MP2-V Smoke | N⁵ | water-scs-mp2-2terfc.toml, water-mp2v.toml, water-attmp2.toml |
| Long-range correlation from response | RS-MP2 + LR-RPA, or PDEP-RPA | CLI rs-mp2-rpa, pdep-rpa | RS-MP2+RPA Smoke; PDEP-RPA Proven | N⁵ (MP2 part); N⁴ (RI-RPA) | water-rs-mp2-rpa.toml, water-pdep-rpa.toml |
| Ionization potentials / electron affinities | G0W0@PBE (or @HF); U-GW for open shells | CLI gw, Python run_gw, run_u_gw | Smoke, about ±0.3 eV | — | water-g0w0-pbe.toml, oh-ugw.toml |
| Excitation energies | BSE-TDA on G0W0@HF; or TDA/TDDFT (CIS/TDHF on an HF reference) | CLI bse-tda, tda, tddft; Python run_bse_tda, run_tddft | BSE-TDA Smoke; TDA/TDDFT Proven (narrow, closed shell) | — | water-bse-tda.toml, water-tda.toml |
| Static polarizability | PDEP-RPA properties, or RPAx@KS (static α only) | CLI pdep-rpa with compute_polarizability, tdhf-static-polarizability | PDEP-RPA Proven (energy); RPAx Smoke | N⁴ (RI) | h2o-pdep-rpa-props.toml, water-tdhf-static-alpha.toml |
| \( C_6 \) dispersion coefficients | PDEP-RPA dynamic α (c6_source = "pdep") or TS/MBD, in an augmented basis. Not RPAx: its \( C_6 \) is ~63% low | CLI pdep-rpa + [rpa] compute_c6 | which source is better is not established | N⁴ (RI) | water-c6-pdep.toml, argon-c6-rpa-pbe.toml |
| Implicit solvation | IEF-PCM (CLI [pcm], Python solvent=) or COSMO (CLI [cosmo]) | see SCF and DFT | both cross-checked against PySCF on water | SCF cost | — |
| Embedding in a protein or solvent | QM/MM | Python run_qmmm; CLI [qmmm] (point charges) | see QM/MM | SCF cost | water-qmmm.toml |
| Electron-transfer coupling | constrained DFT + Wu–Van Voorhis \( H_{ab} \) | Python run_cdft, cdft_coupling (no CLI) | no external reference | SCF cost × outer λ loop | — |
| Atomic charges, ESP | Löwdin, Hirshfeld, CHELPG, RESP; ESP at nuclei or on the surface | Python | see Capabilities and validation | SCF 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 basis | RI (correlation) aux | JK aux (for df_j_aux / df_k_aux) |
|---|---|---|
| cc-pVDZ | cc-pvdz-ri (alias cc-pvdz-rifit) | def2-universal-jkfit |
| cc-pVTZ | cc-pvtz-rifit | def2-universal-jkfit |
| aug-cc-pVDZ / TZ / QZ | aug-cc-pvdz-rifit, aug-cc-pvtz-rifit, aug-cc-pvqz-rifit | def2-universal-jkfit |
| def2-SVP, def2-TZVP, def2-QZVP | def2-svp-rifit, def2-tzvp-rifit, def2-qzvp-rifit | def2-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:
ksdftwithmultiplicity > 1runs UKS, anduhf/rohfwith[dft] functionalrun 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
| PySCF | ferric |
|---|---|
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.nelectron | mol.nelec() |
mol.natm | mol.natoms() |
mol.atom_coords() (Bohr) | mol.coords_bohr() |
mol.atom_coords(unit="Angstrom") | mol.coords() |
mol.elements | mol.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
| PySCF | ferric |
|---|---|
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_tot | r.energy (RHF/UHF/ROHF) or r.total_energy (DFT) |
mf.converged | r.converged |
mf.mo_energy | r.orbital_energies() (RHF), r.orbital_energies_alpha() / _beta() (UHF/ROHF) |
mf.mo_coeff | r.mo_coefficients() (RHF only) |
mf.make_rdm1() | r.density() (RHF, DFT), r.density_alpha() / r.density_beta() (UHF/ROHF) |
mf.level_shift = 0.2 | run_rhf(..., level_shift=0.2) |
mf = solvent.PCM(scf.RHF(mol)); mf.with_solvent.eps = 78.4 | ferric.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
| PySCF | ferric |
|---|---|
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
| PySCF | ferric |
|---|---|
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
-O3miscompiles 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
| scheme | what it does |
|---|---|
keep | leave the host charge in place |
delete-host | Z1 — delete it |
rc | Lin–Truhlar redistributed charge |
rcd | redistributed 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
prmtopreader. 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 inrun_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:
| Call | What it does | Scope |
|---|---|---|
ferric.run_saddle(mol, basis, xc=...) | P-RFO search for a first-order saddle point | Closed 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 connects | Closed 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
FrequencyResultdoesn'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
| Signal | Meaning |
|---|---|
converged = true | Trust the energy. Absence of this line is not success. |
converged = false | An 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 error | Your .xyz atom count is wrong. Read the arithmetic it prints. |
| Disagreement with another code ~1e-5 Ha | Check the grid: ferric (75,110) vs PySCF ~(75,302). Scales with atom count. |
| Larger KS-DFT disagreement that grows with size | ferric density-fits Coulomb by default in KS-DFT. See Recipes §6. |
| A correlation energy that moves when nothing physical changed | density_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/admetreturns HTTP 404. The client uses the live but undocumentedPOST /api/single/admetinstead, 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 ...]
| flag | effect |
|---|---|
--offline | local RDKit screen only; makes no network call |
--require-online | an unavailable online provider is a hard failure (exit 2), not a degraded run (exit 3). Cannot be combined with --offline |
--fail-on-alerts | exit 4 when any molecule has a structural alert |
--timeout SECONDS | wall-clock limit per web request (default 20). A provider that does not answer is not retried for the rest of the run |
--json | machine-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
| code | meaning |
|---|---|
| 0 | clean: every molecule assessed by every provider that was asked to |
| 1 | usage or input error: a bad flag, an unparseable SMILES, a duplicate label, or nothing to assess |
| 2 | a 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 |
| 3 | online checks unavailable: the local screen ran and is reported in full; each unavailable provider is named with its reason |
| 4 | structural 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
| Channel | What | How |
|---|---|---|
| PyPI | Built 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 nightly | Built 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 want | Use | Where it's documented |
|---|---|---|
| Which methods exist, with which tasks and validation grade | — | Capabilities and validation |
| An energy from a SMILES string | tools.structure.from_smiles + ferric.run_dft | Recipes §0 |
| An energy from a TOML file | CLI, method.kind | Recipes §1, input reference |
| An ion, radical or metal center | [molecule] charge, multiplicity (Python: on Molecule.from_xyz) | Recipes §2 |
| An optimized geometry | method.task = "optimize" | Recipes §3 |
| Harmonic frequencies | method.task = "frequencies", ferric.run_frequencies | Finite differences of analytic gradients; examples/water-frequencies.toml |
| A transition state and its reaction path | ferric.run_saddle, ferric.run_irc (Python only, closed shell) | Golden paths Step 5 |
| A screen of many ligands | tools.pipeline.run_funnel | Recipes §4 |
| A pose relaxation or binding energy | tools/active_site/ | Pipeline notes |
| A residue ranking for mutation | pocket_charges + pocket_field, then QM/MM | Recipes §5. It ranks hypotheses and doesn't design mutations. |
| QM/MM embedding | ferric.QmmmSystem, ferric.run_qmmm, CLI [qmmm] | QM/MM |
| Toxicity and liability flags | python -m tools.tox | Toxicity screening |
| A machine-readable record of a run | <input>.ferric.jsonl, written by default | Run logs |
| Python instead of TOML | the ferric module | Python 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
- Charge/multiplicity parity. An odd electron count needs an even
multiplicity. The error message prints the arithmetic and the rule, and it
means your
.xyzor your charge is wrong. ferric can handle ions. - 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 ferricand the wheel are both release builds../target/debug/ferricis not. - MPI is a workspace feature, not a CLI one.
ferric-clihas nompifeature of its own, so-p ferric-cli --features mpifails. The working build iscargo build --release --workspace --features mpi. - Threading. Don't set
OPENBLAS_NUM_THREADSabove 1. The CLI andimport ferricpin OpenBLAS to one thread when the variable is unset and honour an explicit value, andcargosets 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. - Under-converged density.
energy_convalone doesn't converge the density. Correlation energies and properties inherit the full first-order density error. E_HF doesn't, because it's variational. Setdensity_conv. - 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 -falso matches your owngrep, abash -cwrapper, 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.
Terminatedusually means timeout(1), not the OOM killer. Check whether atimeoutwrapped the command before you look indmesg.- 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.
| Family | Methods | Page |
|---|---|---|
| SCF and DFT | RHF, 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 IRC | SCF and DFT |
| MP2 | RI-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-MP2 | The MP2 family |
| Coupled cluster | CCD, CCSD, CCSD(T), LinLCCD (hh, drivers-only, full; exact or local); double hybrids B2PLYP, DSD-PBEP86, ωB97X-L-V | Coupled cluster |
| Response | PDEP-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 transfer | cDFT, \( 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:
epsis 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": nullmarks an exact run). Report it with any number you quote.eps = 0is 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 ofepsraises 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 (
rimp2with[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 withoutintegral_directmakes 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] functionalis 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 fortask = "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
ksdftandrun_dft(def2-universal-jkfit), whilerhfis 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.
| Model | What it is | How to run it | Anchor |
|---|---|---|---|
IEF-PCM (ferric-pcm) | Integral-equation PCM; modified Bondi radii (H 1.10 Å), atom-centred Lebedev spheres | CLI [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 screening | CLI [cosmo] epsilon = 78.39 | water / 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.shinstalls). - 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] setting | What it is | Exact? | Scope |
|---|---|---|---|
| (default) | Schwarz-screened direct four-centre J + K | yes | all SCF types |
k_builder = "link" | LinK: pair-list-screened direct K | yes (== direct to 9e-12 Ha, butane/def2-SVP) | RHF, UHF, ROHF |
df_j_aux / df_k_aux | density-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 RIJCOSX | grid-dependent error, see below | RHF/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):
| Builder | What the time covers | def2-TZVP | def2-QZVP |
|---|---|---|---|
| direct | J and K together (one integral sweep) | not measured | 400 |
| LinK | K | being re-measured | being re-measured |
| RI-JK | K | 0.05 | 0.43 |
| COSX | K | not measured | 90 |
Time for a full SCF (seconds, butane, one thread):
| Builder | def2-TZVP | def2-QZVP |
|---|---|---|
| direct | 98 | not measured |
| LinK | being re-measured | being re-measured |
| RI-JK | not measured | not measured |
| COSX | 358 | not measured |
Energy error against exact exchange:
| Builder | System / basis | Error |
|---|---|---|
| LinK | butane / def2-SVP | 9e-12 Ha |
| COSX, default (sgx (35,194) + final pass on sgx (50,302)) | water / aug-cc-pVDZ | −8.1e-9 Ha |
| COSX, default | butane / def2-SVP | −3.4e-5 Ha |
| COSX, default | butane / def2-TZVP | −4.3e-6 Ha |
| COSX, flat (50,110) | water / cc-pVDZ | 5e-6 Ha |
| COSX, flat (50,110) | butane / def2-SVP | 1.7e-4 Ha |
| COSX, flat (50,110) | butane / def2-TZVP | 1.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.angularmust 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 peakangular— 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 withoutpruneis flat.cosx_final_pass = true(the default;falseturns 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_gridpicks 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.0disables screening bit-identically;1e-6already 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:
| System | atoms | flat (50,110) | sgx (35,194) | sgx (35,194) + final sgx (50,302), default | sgx (50,302) | sgx (50,194) | flat (50,194) | sgx (35,194), no fit |
|---|---|---|---|---|---|---|---|---|
| water / aug-cc-pVDZ | 3 | +6.1e-6 | +1.9e-6 | −8.1e-9 | −8.1e-9 | +9.8e-7 | +1.6e-7 | +1.6e-6 |
| butane / def2-SVP | 14 | +1.7e-4 | +4.6e-5 | −3.4e-5 | −3.4e-5 | +4.1e-5 | +5.0e-5 | −2.7e-4 |
| butane / def2-TZVP | 14 | −1.2e-4 | −4.1e-5 | −4.3e-6 | −4.3e-6 | −3.2e-5 | −1.6e-5 | −1.2e-4 |
| benzene / def2-SVP | 12 | +8.2e-5 | −4.8e-5 | +5.6e-6 | +5.6e-6 | −4.8e-5 | −4.0e-5 | −8.8e-5 |
| sulfamethoxazole / def2-SVP | 28 | +2.5e-4 | −1.9e-5 | +2.2e-5 | +2.1e-5 | −2.3e-5 | −2.3e-5 | −1.9e-4 |
| methane / cc-pVDZ | 5 | +8.0e-6 | +9.2e-6 | +6.5e-7 | +6.5e-7 | +1.0e-5 | +5.1e-6 | −1.9e-4 |
| ethane / cc-pVDZ | 8 | +1.2e-4 | −4.3e-6 | −2.6e-6 | −2.6e-6 | −4.5e-6 | +1.7e-6 | −9.1e-5 |
| propane / cc-pVDZ | 11 | +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 atom | 5500 | 3545–3629 | 3545–3629 SCF, 8109–8214 final | 8109–8214 | 4996–5068 | 9700 | 3545–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):
| System | exact K | flat (50,110) | sgx (35,194) | sgx (35,194) + final | sgx (50,302) |
|---|---|---|---|---|---|
| water / aug-cc-pVDZ | 0.3 s | 15.7× | 10.1× | 12.8× | 21.7× |
| butane / def2-SVP | 2.6 s | 17.7× | 11.8× | 14.3× | 25.5× |
| butane / def2-TZVP | 19.0 s | 5.4× | 3.9× | 4.4× | 7.5× |
| benzene / def2-SVP | 3.2 s | 13.2× | 8.8× | 10.6× | 18.9× |
| sulfamethoxazole / def2-SVP | 78.7 s | 7.2× | 5.9× | 6.5× | 14.4× |
| methane / cc-pVDZ | 0.2 s | 27.0× | 20.5× | 22.5× | 40.2× |
| ethane / cc-pVDZ | 1.0 s | 18.7× | 12.7× | 15.8× | 28.7× |
| propane / cc-pVDZ | 3.6 s | 13.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 underterf-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):
| Variant | Parameters | Basis of the fit | Source |
|---|---|---|---|
| erfc | ω; ferric's default is 0.420 Å⁻¹, recorded in the code as the dissertation's erfc optimum | aug-cc-pVDZ in the 2012 paper | Goldey & Head-Gordon 2012 |
| terfc | one cutoff \( r_0 \) | aug-cc-pVTZ | Goldey, Dutoi & Head-Gordon 2013 |
| SCS-MP2(2terfc) | \( r_0(1) \) = 0.75 Å, \( r_0(2) \) = 1.05 Å, cOS = 1.27, cSS = 4.05 | aug-cc-pVTZ, no counterpoise, frozen core | Goldey & Head-Gordon 2014 |
| MP2-V | \( r_0 \) = 1.00 Å, b = 11.0, C = 0.0089, terfc | aug-cc-pVTZ, no counterpoise, frozen core | Goldey, 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.
| Variant | method.kind | Example | Python |
|---|---|---|---|
| erfc | att-rimp2 | examples/water-attmp2.toml | run_attenuated_rimp2(..., omega=0.420) (Å⁻¹) |
| terfc | att-rimp2 with [mp2] att_operator = "terfc", att_r0 (Å) | examples/water-attmp2-terfc.toml | run_terfc_rimp2(..., r0=...) |
| SCS-MP2 (Grimme) | scs-mp2 | examples/water-scs-mp2.toml | run_scs_mp2(..., c_os=, c_ss=) |
| SCS-MP2(2terfc) | scs-mp2-2terfc | examples/water-scs-mp2-2terfc.toml | run_scs_mp2_2terfc(...) |
| MP2-V | mp2-v | examples/water-mp2v.toml | run_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) andaoare both exact and agree to round-off.ao-sparserestricts each Boys-localized orbital's pseudo-density to an AO domain of radiusdomain_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'scc.CCSDuses. 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); Pythonferric.run_ccsd(mol, bs, aux). - CCSD(T):
method.kind = "ccsd(t)"(examples/water-ccsd-t.toml); Pythonferric.run_ccsd_t(mol, bs, aux). - CCD:
method.kind = "ccd"(examples/water-ccd.toml); Pythonferric.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.
| Quantity | System / basis | Reference | Agreement | Pinned by |
|---|---|---|---|---|
| CCSD correlation energy | H2 / STO-3G | exact-integral numpy | −0.02052453 Ha | ferric-cc/src/ccsd.rs::test_ccsd_h2_sto3g |
| Closed-shell (T) | H2O / cc-pVDZ | PySCF 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-pVDZ | ferric's former dense path | 5e-16 Ha | ferric-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); Pythonferric.run_pdep_rpa. Proven. - U-PDEP-RPA, open shell over a spin-summed dielectric. From the CLI, set
method.kind = "pdep-rpa"withmultiplicity > 1andtask = "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: Pythonrun_pdep_rpais 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:
| Size | Local dRPA (amplitudes kept) | PDEP (modes kept) |
|---|---|---|
| C4 | 23.3% | 17.9% |
| C8 | 10.0% | 21.5% |
| C12 | 4.9% | 22.6% |
| C16 | 2.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.
| Quantity | System / basis | Reference | Pinned 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 validated | ferric-gw/tests/validation_gw.rs |
| G0W0@PBE HOMO IP | H2O / cc-pVDZ | PySCF gw_ac, 11.1714 eV; asserted to <0.1 eV | ferric-gw/tests/g0w0_pbe_h2o.rs |
| U-G0W0@UKS/PBE quasiparticle energies, Σx − v_xc inside each spin's equation | OH, CH3, NH2 / cc-pVDZ | PySCF ugw_ac Σc(ef + iω), quasiparticle equation solved in numpy: ≤2.6e-8 Ha on orbitals whose quasiparticle equation has a single root | ferric-gw/tests/validation_gw.rs |
| Σx − v_xc placement in U-GW (inside the quasiparticle equation; none for a UHF reference) | OH / STO-3G | internal: shifted residual, bit-identity of the no-shift path | ferric-gw/tests/u_gw_ks_shift.rs |
| U-G0W0@UHF α-HOMO IP | OH / cc-pVDZ | ~13–14 eV window, brackets experiment 13.02 eV | ferric-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 aCdftResult: a constrained UHF solve, or UKS whenfunctionalnames a libxc functional other than"HF"(Noneand"HF", any case, give UHF).CdftConstraint(atoms, target, kind="charge")defines one fragment constraint.atomsare 0-based atom indices.targetis the electron population on the fragment, the Becke-weighted trace \( \mathrm{Tr}[W D] \), not a net charge: \( N_\alpha + N_\beta \) forkind="charge"and \( N_\alpha - N_\beta \) forkind="spin". A neutral He atom has a charge population of 2.0; He⁺ has 1.0.cdft_coupling(state_a, state_b)returns aCdftCouplingResultwith the Wu–Van Voorhis couplingh_ab, the determinant overlaps_aband the two diabat energiese_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_cdftraisesRuntimeError. The inner SCF at the final λ can still be unconverged, so checkr.converged: it is true only when the inner SCF converged and every constraint is met tolambda_tol. stability_descentdefaults to True here (inrun_uhfit 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. Settinggrid_radialorgrid_angularuses 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=Noneor"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_couplinghas strict preconditions and raisesValueErrorwhen one fails. Each state carries exactly onekind="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 ofh_abis 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:
| Object | Definition | Where it appears in ferric |
|---|---|---|
| Density response \( \chi \) | \( \chi = \delta\rho / \delta v_{\text{ext}} \); \( \chi_0 \) is its independent-particle form | RPA, GW, MP2's dispersion |
| Dielectric matrix \( \varepsilon \) | \( \varepsilon = 1 - v\chi_0 \) (RPA), with \( v \) the Coulomb kernel | PDEP-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 potential | constrained 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:
- 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.
- 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_sizespins 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 (
rimp2with[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 withoutintegral_directstill 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.
| Grade | Meaning |
|---|---|
| Proven | Total 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) |
| Smoke | Runs 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. |
| Spike | Built on new infrastructure and not yet compared against any reference code; for exploration only |
| not graded | Dispatched 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.kind | Family | Reference (CLI) | Energy | task = "optimize" | task = "frequencies" | Python | Example | Grade | Caveat |
|---|---|---|---|---|---|---|---|---|---|
rhf | SCF | RHF; RKS with [dft] functional (refuses multiplicity > 1) | ✓ | ✓ analytic | analytic (RHF, exact J/K, no ECP, up to f); FD otherwise | run_rhf, run_optimize, run_frequencies | water-rhf.toml | Proven | — |
uhf | SCF | UHF; 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 otherwise | run_uhf, run_frequencies(reference="uhf", xc=...) | h_uhf.toml | Proven | — |
rohf | SCF | ROHF; ROKS with [dft] functional | ✓ | ✓ analytic (+ D3(BJ) or MBD@rsSCS gradient with [dft] dispersion on ROKS) | FD | run_rohf, run_frequencies(reference="rohf", xc=...) | — | Proven | — |
ksdft | SCF/DFT | RKS; 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_prune | run_dft / run_ksdft, run_frequencies(xc=..., dispersion=...) | benzene-dfb3lyp.toml, h2-lda-opt.toml | Proven | — |
rimp2 | MP2 | RHF; 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.toml | Proven | Exact 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. |
mp3 | MP2 | RHF | ✓ | — | — | run_mp3 | water-mp3.toml | Proven | — |
oo-rimp2 | MP2 | RHF; UHF + unrestricted OO-RI-MP2 when multiplicity > 1 (energy only) | ✓ | — | — | run_oo_rimp2 (closed shell only) | water-oo-rimp2.toml | Proven (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-rimp2 | MP2 | RHF | ✓ | — | — | run_attenuated_rimp2 | water-attmp2.toml | Proven | — |
mp2-v | MP2 | RHF; UHF when multiplicity > 1 (energy only) | ✓ | — | — | run_mp2_v (closed shell only) | water-mp2v.toml | Smoke | The 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-mp2 | MP2 | RHF | ✓ | — | — | run_scs_mp2 | water-scs-mp2.toml | Proven | — |
scs-mp2-2terfc | MP2 | RHF | ✓ | — | — | run_scs_mp2_2terfc | water-scs-mp2-2terfc.toml | Proven | Needs the terfc tables (FERRIC_TERF_TABLE_DIR). |
laplace-mp2 | MP2 | RHF | ✓ | — | — | run_laplace_mp2 | water-laplace-rimp2.toml | Proven | Minimax-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-mp2 | MP2 | RHF | ✓ | — | — | run_laplace_sos_mp2 | water-laplace-sos-mp2.toml | not graded | With c_os = 1.0 it reproduces the opposite-spin MP2 energy (internal reference). |
pdep-rpa | RPA/GW | RHF, 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.toml | Proven | — |
rs-mp2-rpa | MP2 / RPA | RHF | ✓ | — | — | run_rs_mp2_rpa | water-rs-mp2-rpa.toml | Smoke | The ω→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. |
gw | RPA/GW | RHF, 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_gw | water-g0w0-pbe.toml, oh-ugw.toml | Smoke | Matches 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-tda | RPA/GW | RHF only (refuses multiplicity > 1) | ✓ (excitations) | — | — | run_bse_tda | water-bse-tda.toml | Smoke | The 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-polarizability | RPA/GW | RKS only ([rpa] xc required) | ✓ (static α) | — | — | run_tdhf_static_polarizability | water-tdhf-static-alpha.toml | Smoke | Static α 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. |
ccsd | CC | RHF (spin-adapted solver) | ✓ | — | — | run_ccsd | water-ccsd.toml | Proven | — |
ccd | CC | RHF only (refuses multiplicity > 1) | ✓ | — | — | run_ccd | water-ccd.toml | Proven (narrow) | RI-CCD, spin-orbital solver. |
ccsd(t) | CC | RHF only (refuses multiplicity > 1) | ✓ | — | — | run_ccsd_t | water-ccsd-t.toml | Proven (narrow) | Spin-adapted CCSD + spin-adapted (T). Prints E_CCSD, E_(T) and the total. |
linlccd | CC | RHF only (refuses multiplicity > 1; open-shell LinLCCD(hh) is library-only) | ✓ | — | — | run_linlccd | water-linlccd.toml; local: water-linlccd-local.toml | Proven (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. |
drpa | RPA/GW | RHF only (refuses multiplicity > 1) | ✓ | — | — | run_drpa, run_drpa_scan (local ε scan) | water-drpa.toml; local: water-drpa-local.toml | Proven (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-v | CC § ωB97X-L-V | Its own RKS (wB97X-L-V) reference (refuses multiplicity > 1; open shell is library-only) | ✓ | — | — | none | water-wb97xlv.toml | Proven (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. |
b2plyp | CC § double hybrids | Its own RKS reference | ✓ | — | — | run_double_hybrid(kind="b2plyp") | water-b2plyp.toml | Spike | Weighted B88+LYP reference. Not compared with any reference code. |
dsd-pbep86 | CC § double hybrids | Its own RKS reference | ✓ | — | — | run_double_hybrid(kind="dsd-pbep86") | — | Spike | Weighted PBE+P86 reference. Not compared with any reference code. |
tda | RPA/GW § TDDFT | RHF (CIS), or RKS via [tddft] xc (refuses multiplicity > 1) | ✓ (excitations) | — | — | run_tddft(method="tda") | water-tda.toml | Proven (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. |
tddft | RPA/GW § TDDFT | RHF (TDHF), or RKS via [tddft] xc (refuses multiplicity > 1) | ✓ (excitations) | — | — | run_tddft(method="casida") | water-tddft-pbe.toml | Proven (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
uhfandrohfread[molecule] multiplicitydirectly. With[dft] functionalthey run UKS and ROKS, for energies, optimizations and frequencies.ksdftwithmultiplicity > 1runs UKS (theuhfroute with the functional set). For ROKS userohfwith[dft] functional.rhfrefusesmultiplicity > 1and points touhf/rohf.rimp2andoo-rimp2run on the same plain UHF thatkind = "uhf"runs, then take unrestricted RI-MP2 (UMP2, as PySCFmp.MP2(uhf)) and unrestricted OO-RI-MP2.[mp2] kappais refused on an open shell.task = "energy"only: there is no unrestricted MP2 nuclear gradient.pdep-rpa,gwandmp2-vsolve UHF with MOM after 5 iterations whenmultiplicity > 1, fortask = "energy"only. Forpdep-rpaandgw, setting[rpa] xcmakes that reference UKS;gwwith[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-vdoes not read[rpa] xcand stays UHF.linlccdandwb97x-l-vrefuse 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,ccsdandccsd(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 > 1with 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.
| Capability | Python | Scope (verified in code) |
|---|---|---|
| Open-shell KS frequencies | run_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 search | run_saddle | P-RFO. Closed shell only (refuses multiplicity ≠ 1). Raises if the start has no negative mode. Costs 2(6N+1) + (steps+1) gradients. |
| Reaction path | run_irc | Both IRC branches from a saddle's imaginary mode. Closed shell only. |
| Geometry optimization (Python) | run_optimize | RHF only (no xc argument). Accepts point charges and a field. |
| QM/MM energy + forces | QmmmSystem, run_qmmm | method = "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 optimization | run_optimize_qmmm | Same four methods. move_mm = "none"/"all"/("within", r)/("residues", [...]). Moving MM atoms requires mm_topology. |
| Constrained DFT | run_cdft, CdftConstraint | UHF, 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 coupling | cdft_coupling | Wu–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 field | point_charges=, external_field= on run_rhf/run_uhf/run_rohf/run_dft, run_optimize, run_frequencies, run_saddle, run_irc, run_pdep_rpa | Bohr 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_energy | Additive, with parameters fitted per functional. run_frequencies takes it on the closed-shell reference only. |
| MBD@rsSCS | run_dft(dispersion="mbd"), run_frequencies(xc=..., dispersion="mbd"), mbd_rsscs_energy | run_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. |
| Charges | mulliken_charges, lowdin_charges, hirshfeld_charges, chelpg_charges, resp_charges | Take 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 potential | esp_at_atoms, esp_at_points | Evaluated exactly from the density. esp_at_points takes (N, 3) points in Bohr. |
| Polarizability / moments | hirshfeld_polarizability, orbital_moments, density_second_moment | — |
| ω tuning | tune_omega | Range-separation ω for a named functional. |
| Conformer statistics | boltzmann_weights, weighted_stats*, ConformerEnsemble | — |
| Integrals | compute_eri3, compute_eri3_mo, compute_metric_2c, boys_localize, shell_info | Low-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_/)').
| Capability | System / basis | Reference | Stated agreement | Test tolerance | Proof |
|---|---|---|---|---|---|
| UHF and ROHF energies, stability-checked | HO2, NO2, CH2 (triplet), allyl / 6-31G, def2-SVP | PySCF UHF/ROHF + stability() | 4.0e-12 Ha (energy); 3e-7 (⟨S²⟩) | 1e-10 Ha; 1e-6 | validation_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 construction | N2⁺, 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 Ha | validation_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 eigenvalues | N2 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 operators | energy ≤ 8.8e-10 Ha; eigenvalues ≤ 1.3e-9 Ha; the plain-DIIS rung lands on the N2 saddle 1.906e-2 Ha higher | 1e-8 Ha; 1e-8 Ha | validation_scf_ladder.rs, rhf_stability_descent.rs, gen_scf_ladder.py |
| RHF and UHF with def2 ECPs: energies and analytic gradients | HI, 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-11 | 2.5e-7 Ha; 1e-7 Ha/Bohr | validation_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 energy | HF: 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-rifit | HF: 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 EnGrad | HF: 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 ORCA | HF: 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 nuclei | H2O, CH3OH (RHF), HO2 (UHF) / cc-pVDZ, def2-SVP | PySCF 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 SCF | 5e-13 a.u.; 5e-8 a.u. | validation_density_properties.rs, gen_properties.py |
| Electric field at the nuclei | H2O, CH3OH, HO2 / cc-pVDZ, def2-SVP | PySCF int1e_iprinv, same density (checked against a finite difference of the ESP) | 1.5e-13 a.u. same density; 2.7e-9 own SCF | 1e-12 a.u.; 3e-8 a.u. | ″ |
| Becke effective volumes | H2O, 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-pVDZ | numpy on ferric's grid rebuilt from PySCF's radial, Lebedev and AO values, with ferric's Becke partition; free atoms vs PySCF UKS | 1.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 Ha | 1e-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 e | 1e-10 e; 1e-6 e; 1e-3 e; 5e-5 e | validation_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 on | H2O, 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 point | 1.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 density | 1e-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 energy | H2O, CO, CH3OH / cc-pVDZ, def2-SVP; CH4, benzene / cc-pVDZ (Hirshfeld volume ratios); ferric's and pymbd's frequency grids | pymbd 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 forms | TS ≤ 7.5e-16 rel.; MBD 8.1e-15 against the same A&S erf ferric uses, 5.1e-7 against an exact erf | 1e-12; 1e-11; 1e-6 | validation_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 grid | H2O, CO, CH3OH / cc-pVDZ, def2-SVP; CH4, benzene / cc-pVDZ (Hirshfeld volume ratios); β = 0.83 (PBE) and 0.85 (PBE0/HSE06), a = 6 | pymbd 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-14 | 1e-10; 1e-12; 1e-12 | validation_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 FDs | explicit 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-14 | 1e-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-12 | validation_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 geometry | H2O / STO-3G, PBE (D3(BJ), MBD@rsSCS); H2 / STO-3G HF with a synthetic harmonic-spring correction | D3: 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 bit | D3 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-7 | dispersion_frequencies.rs |
| Static α (direct RPA with a density-fitted Coulomb kernel, no exchange; not CPHF) | H2O / aug-cc-pVDZ; CH3OH / cc-pVDZ | numpy linear solve on PySCF int3c2e with the same aux basis | 4.8e-14 rel. same orbitals; 5.3e-10 rel. own SCF | 5e-13; 5e-9 | validation_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-atom | H2O, 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-3 | validation_pdep_c6.rs, gen_pdep_c6.py |
NPZ export ([rpa] export_npz) | water / STO-3G, CLI pdep-rpa | numpy.load of the CLI's file against the Python bindings | exact (keys, shapes, dtypes, C order, geometry); per-atom and molecular α origin-independent to 3.4e-13 rel.; alpha_ct = alpha_tensor − Σ_A alpha_atomic | exact; 1e-11 rel. under translation; 1e-13 | test_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 keys | water / cc-pVDZ, CLI pdep-rpa with exact J/K | numpy.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 table | integral 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 exactly | 5e-13; 1e-8 (density), 3e-9; 1e-9; 5e-11, 1e-6; 1e-12 | validation_npz.rs, gen_npz.py |
| KS-DFT energies: open-shell UKS and second-row closed shell | UKS: NH2, CH3, HO2, O2 (triplet) × PBE, B3LYP, ωB97X-V / 6-31G, def2-SVP; RKS: H2S, HCl, SiH4 × PBE, B3LYP, HSE06 / def2-SVP, def2-TZVP | PySCF 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 metric | energy ≤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-6 | validation_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-SVP | PySCF 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 Ha | 1e-11 Ha; OH 5e-6 Ha | validation_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-TZVP | PySCF RKS/UKS + stability(), same grid, density fitting and XC density floor | RKS energy ≤ 4.0e-12 Ha, UKS ≤ 2.4e-13 Ha; frontier orbitals ≤ 1.1e-8 Ha; ⟨S²⟩ ≤ 5.2e-10 | energy 5e-11 Ha; orbitals 1e-7 Ha; ⟨S²⟩ 5e-9 | validation_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 root | PySCF 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 each | NWChem 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 Ha | validation_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 descent | HeNe⁺ (N_He = 2, R = 2.0 Å), LiH⁺ (N_Li = 2.8, R = 3.0 Å) / def2-SVP, two states each | NWChem 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-4 | validation_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 end | He₂⁺ at 2.50/3.00/3.50 Å / def2-SVP, aug-cc-pVDZ | NWChem 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 invariance | kernel ≤ 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 cavity | water, NH3 / STO-3G, cc-pVDZ; ε = 78.4, 4.7 | PySCF RHF.PCM() IEF-PCM; its SWIG cavity injected into ferric | charges 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 PySCF | 1e-11 Ha (solver); 1e-10 Ha (SCF); own cavity ±2% | validation_pcm.rs, gen_pcm.py |
| Geometry optimization, RHF and RKS | H2O, NH3, CH2O from distorted starts / RHF and B3LYP 6-31G, PBE cc-pVDZ | PySCF analytic gradient (KS with grid response) driven to max abs gradient ≤ 1e-6 Ha/Bohr by scipy BFGS; exact J/K | distances ≤ 2.6e-6 Bohr; angles ≤ 1.1e-4°; optimized energies ≤ 9.3e-13 Ha | 2e-5 Bohr; 1e-3°; 1e-10 Ha | validation_geometry_optimization.rs, gen_geometry_optimization.py |
| Geometry optimization, UHF, ROHF and UKS | HO2, CH3, NH2 from distorted starts / UHF, ROHF, UKS-PBE 6-31G | PySCF analytic gradient driven to max abs gradient ≤ 1e-6 Ha/Bohr by scipy BFGS; stability() at start and end | distances ≤ 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@HF | H2O / cc-pVDZ | Published 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 core | exact-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 Ha | 1e-10 Ha | validation_cc.rs, gen_cc.py, reference data |
| RI-CCD | H2O, NH3 / cc-pVDZ, def2-SVP | exact-integral PySCF RHF, then the same aux basis and DF factorization as ferric; PySCF CCD on the DF integrals | ≤ 1.9e-11 Ha | 2e-10 Ha | validation_cc.rs, gen_cc.py, reference data |
| RI-CCSD, spin-orbital and spin-adapted | H2O, 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 Ha | validation_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-12 | 1e-10 Ha | validation_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 energies | N2 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 orbital | PySCF 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 lower | E_CASCI ≤ 1.4e-11 Ha; e_core and the active-space energy ≤ 2.6e-10 Ha; E_RHF 2.2e-12 Ha | 3e-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-pVDZ | PySCF 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 Ha | 1e-6 Ha (Σc(iω) 1e-9) | validation_gw.rs, gen_gw.py |
| G0W0@HF with ECPs | I2, 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@UHF | OH, CH3, NH2 / cc-pVDZ, aug-cc-pVDZ; O2 and CH2 triplets / aug-cc-pVDZ | PySCF 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 Ha | validation_gw.rs, gen_gw.py |
| COHSEX (@HF and @PBE), evGW₀, evGW (@HF) | COHSEX: H2O, N2 / cc-pVDZ; evGW₀, evGW: H2O / cc-pVDZ | numpy 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 rule | COHSEX@HF 9.4e-10 Ha; COHSEX@PBE 1.2e-9 Ha; evGW₀/evGW 5.7e-7 Ha | 1e-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 COHSEX | numpy 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-10 | 3e-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 spin | OH, 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 side | U-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-10 | 1e-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 singlets | numpy 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-CIS | 2e-9 Ha (Ω), 3e-8 (f), 2e-6 Ha (own QP) | validation_bse.rs, gen_bse.py |
| TDA and Casida TDDFT excitation energies | water, formaldehyde, NH3 / 6-31G, aug-cc-pVDZ | PySCF tddft.TDA/TDDFT, same RI and grid | HF ≤ 2e-6 eV; LDA/PBE ≤ 2e-5 eV; B3LYP ≤ 6.5e-4 eV | 1e-3 eV | validation_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 Ha | 2e-8 Ha/Bohr (FD), 2.5e-7 Ha/Bohr (ORCA); 1e-9 Ha | validation_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-pVDZ | PySCF pcm.py COSMO driver with ferric's radii, 110-point cavity and point-charge potential (ferric's model); stock PySCF COSMO for the formulation gap | solvated 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-15 | 1e-10 Ha | validation_attenuated_mp2.rs, gen_attenuated_mp2.py |
| QM/MM electrostatic embedding (RHF): energy, embedding shift, QM gradient, MM forces | H2O + 10 charges / cc-pVDZ, aug-cc-pVDZ; CH3OH + 501 TIP3P charges / 6-31G | PySCF qmmm.mm_charge | energy 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/Bohr | validation_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 Bohr | PySCF 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 Ha | 5e-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 PBE | PySCF qmmm.mm_charge on dft.RKS, grid_response=True | energy ≤ 4.1e-12 Ha; QM gradient ≤ 2.9e-9, MM forces ≤ 1.8e-10 Ha/Bohr | 1e-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 rows | H2O + 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 difference | total 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/Bohr | validation_thole.rs, gen_thole.py |
| MM force field (ferric-mm): bond, angle, torsion, Coulomb and LJ energies and gradients, exclusion and 1-4 pair sets | ALA-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 zeroed | energies ≤ 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 identical | 1e-11 (energy), 1e-9 (gradient), relative | validation_mm.rs, gen_mm.py |
| External potential in the RI-MP2 analytic gradient | H2O + 10 charges / cc-pVDZ; CH3OH + 20 charges / 6-31G; aux cc-pVDZ-RI | 5-point finite difference of PySCF DFMP2 on qmmm.mm_charge RHF, same aux | energies ≤ 8.4e-12 Ha; RI-MP2 gradient ≤ 4.8e-9 Ha/Bohr vs PySCF FD | 1e-10 Ha; 3e-8 Ha/Bohr | validation_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 DFMP2 | E_corr (OS, SS, total) ≤ 3.3e-12 Ha at κ = 0.5, 1.1, 2.0; κ → ∞ equals RI-MP2 exactly | 3e-11 Ha | validation_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 error | MO ≡ 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 ≤ 1000 | 1e-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 map | H2O, n-butane / cc-pVDZ, 6-31G (cc-pvdz-ri), frozen core 0 and one per heavy atom; finite ε on n-butane / cc-pVDZ | PySCF 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 compared | E_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-MP2 | 1e-10 Ha (ε = 0); finite ε: under-correlation that grows with ε, no tolerance | validation_lmp2_amplitude.rs, gen_lmp2_amplitude.py |
| LinLCCD(hh) correlation energy | H2O, 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/DFUMP2 | LinLCCD(hh) ≤ 4.4e-13 Ha closed shell, 1.2e-12 Ha OH UHF; ladder off equals RI-MP2 to 1.1e-16 | 1e-11 Ha | validation_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 variant | H2O, 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 DFMP2 | local ≤ 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-4 | 5e-12, 3e-12, 2e-12 Ha; 3e-12; 5e-12 | validation_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-QZVPPD | PySCF 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/mol | 1e-8; 1e-11; 5e-7 Ha; 0.3 kcal/mol | validation_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 energy | 1e-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 αα, ββ, αβ blocks | OH, CH3, NH2, O2 (triplet), HO2 / cc-pVDZ (cc-pvdz-ri); HO2 also with frozen core 2 | PySCF 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 Ha | E_UHF ≤2.8e-12 Ha; every MP2 energy ≤9.2e-10 Ha (HO2), ≤5.8e-11 Ha elsewhere | 3e-11 Ha; 1e-8 Ha | validation_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 atom | Energy: 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 energy | OO 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 difference | 2.5e-7 Ha; 5e-8 Ha/Bohr | validation_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 path | PySCF 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-12 | 1e-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) 4 | H2 / 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-3 | numpy 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-ε reference | 3e-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-11 | 1e-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 component | H2O, 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-11 | 5e-10 Ha | validation_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 grid | E_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-9 | 1e-12 Ha (E_c); 2e-8 (same step), 2e-7 (5-point) Ha/Bohr | validation_rpa_gradient.rs, gen_rpa_gradient.py |
| RI-MP2 size-extensivity | H2 dimer at large separation | 2 × monomer | 2e-12 Ha | 1e-7 Ha | rimp2_size_extensivity.rs |
| RHF/UHF/ROHF/KS gradients, including density-fitted J/K | water, OH, HO2 / cc-pVDZ, 6-31G | finite differences of the energy; PySCF df.grad | 1e-7 to 3e-7 Ha/Bohr (FD); ~1e-10 (PySCF) | 1e-6 Ha/Bohr | df_jk_gradient.rs |
| Analytic RHF Hessian (skeleton one- and two-electron, overlap/W and CPHF response terms) and its harmonic frequencies | H2O, NH3, CH2O / cc-pVDZ; distorted H2O / def2-SVP | PySCF hessian.rhf (analytic, and its skeleton partial_hess_elec + hess_nuc); PySCF finite differences of its analytic gradient; ferric's own gradient differenced | total 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-8 | 1e-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 frequencies | OH (²Π), NH2 (²B1), CH2 (³B1) / cc-pVDZ; tilted OH and off-C2v CH2 / STO-3G, 6-31G | PySCF 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 UHF | total 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-31G | PySCF same-step FD of analytic gradients (KS with grid response); PySCF analytic hessian.rhf/uhf/rks/uks; thermo.harmonic_analysis with ferric's masses | same-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 response | 2e-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 J | PySCF 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-9 | 1e-8 Ha/Bohr | validation_mgga_gradients.rs, gen_mgga_gradients.py |
| Meta-GGA gradient, open shell (SCAN, r2SCAN) | UKS: HO2, NH2 / 6-31G; ROKS: NH2 / 6-31G | PySCF UKS grid_response=True + stability(); ROKS: central FD of PySCF ROKS energy; FD of ferric's own energy | UKS ≤ 6.2e-9 Ha/Bohr; ROKS 2.2e-10 vs the FD; energies ≤ 5.7e-13 Ha | 3e-8 Ha/Bohr (UKS); 1e-7 (ROKS) | validation_mgga_gradients.rs, gen_mgga_gradients.py |
| COSX exchange, dense-grid limit | water / cc-pVDZ | direct K | 3.3e-7 | — | cosx_k_anchors.rs; see SCF: choosing how exchange is built |
| COSX SCF energy, (50,110)+fit | water / cc-pVDZ | direct K | 4.9e-6 Ha | — | ″ |
| COSX SCF energy, (50,110)+fit | butane / def2-SVP | direct K | 1.7e-4 Ha | — | ″ |
| COSX SCF energy, (50,110)+fit | butane / def2-TZVP | direct K | 1.2e-4 Ha | — | ″ |
| COSX, open shell | CH3 doublet / cc-pVDZ | direct K | 1.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-3G | finite differences of the COSX energy | 1.6e-9 to 4.3e-9 Ha/Bohr | 1e-6 / 3e-8 Ha/Bohr | cosx_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 gradients | PySCF sgx on ferric's grid recipe (exact J, fit off, no screening); ferric exact-K energy; central FD of the COSX energy | same 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/Bohr | 1e-10 Ha; ≥ 30x; 1e-9 Ha; 2e-7 Ha/Bohr | validation_cosx.rs, gen_cosx.py |
| RIJCOSX energy (RI-J + COSX K): separation into RI-J error + COSX error | water / 6-31G (HF, B3LYP); HO2 / 6-31G (UHF) | the three other corners of (exact or RI) J × (exact or COSX) K, same grid | second-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 Ha | cosx_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 energy | 1.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 screening | water / aug-cc-pVDZ; butane / def2-SVP (RHF) | PySCF 2.13.1 | ≤ 1.8e-12 Ha, identical point counts | 1e-10 Ha | validation_cosx.rs, gen_cosx_pruned.py |
| COSX final-grid pass | water / STO-3G, 6-31G; HO2 / STO-3G | the SCF-grid energy (final grid = SCF grid); SCF converged on the final grid | bit-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 rule | unit sphere | exact monomial integrals; PySCF's rule | exact through degree 23; nodes and weights identical to PySCF's | 1e-12 relative | lebedev.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 (
rimp2with[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 withoutintegral_directstill 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,
notimes the amplitudes themselves (C12 thrashed, then was killed for memory). The CLI andrun_drparefuse a run that cannot fit before the SCF; full-rankpdep-rpagives 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_sizespins 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.
Related pages
- Input reference: every TOML key
- Examples: every shipped input file
- Python bindings
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.rsare#[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)
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
xyz | string | required | path | Standard XYZ in Å, relative to the working directory. Not read when [qmmm] is present (the PQR supplies the geometry). |
charge | integer | 0 | With [qmmm], applies to the QM region. | |
multiplicity | integer | 1 | ≥ 1 | Read 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)
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
name | string | — | 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. |
path | string | — | path to a Gaussian-94 file | Used 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)
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
kind | string | required | rhf 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 tddft | Any 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). |
task | string | "energy" | energy optimize frequencies | optimize: 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
max_iter | integer | 100 | ||
energy_conv | float | 1e-3 | Sanity 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_conv | float | 1e-6 | The real convergence signal. | |
diis_size | integer | 8 | ||
diis | string | "pulay" | pulay adiis ediis (case-insensitive) | An unknown value is an error when the file is loaded. |
diis_switch_thresh | float | 1e-1 | Error level at which ADIIS/EDIIS hand over to Pulay. Ignored for pulay. | |
smearing_sigma | float | none | Hartree | Fermi–Dirac smearing width. Absent means integer occupations. |
guess | string | "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. |
soscf | bool | false | Enables the second-order (Newton) step in the SCF tail. | |
integral_thresh | float | 1e-12 | Integral screening threshold. | |
eri_precision | float | 1e-20 | 0 to 1e-8 | libint 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). |
screening | string | "schwarz" | schwarz csb csam | csb is rigorous and never looser than schwarz. csam is not a bound. It is refused for erfc (short-range) operators. See SCF: screening. |
k_builder | string | "direct" | direct link cosx | Exchange 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_grid | inline table | { radial = 35, angular = 194, prune = "sgx" } | angular ∈ 6/14/26/50/110/194/302/434/590; prune ∈ none sgx nwchem | The 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_pass | bool | true | Re-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_grid | inline table | none | as cosx_grid | The 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_fit | bool | true | Only with cosx; otherwise it is an error. | |
cosx_backend | string | "md3c1e" | md3c1e cosx-a | Only with cosx; otherwise it is an error. cosx-a is the slower cross-check kernel. |
cosx_screen_thresh | float | 1e-7 | ≥ 0 | Only with cosx and md3c1e. 0 disables the screen. |
cosx_half_transform | string | "sparse" | sparse dense | Only with cosx. |
df_j_aux | string | none; def2-universal-jkfit for ksdft, pdep-rpa, rs-mp2-rpa, gw, bse-tda, tdhf-static-polarizability, tda, tddft | aux 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_aux | string | as df_j_aux | aux basis name, or "" | RI-K. Use a JK-fit set. "" selects exact K. |
level_shift | float | 0.0 | Hartree | Virtual-block shift. Left at 0 with a meta-GGA functional, the library applies 0.5. |
mom_after_iter | integer | 0 | Maximum-overlap occupation pinning after this many iterations. 0 = aufbau throughout. | |
verbose | bool | false | One line per SCF iteration. The CLI's --verbose/-v flag ORs into this. | |
df_guess | bool | on | Two-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_aux | string | def2-universal-jkfit | aux basis name | It is an error when df_guess is off. |
df_increments | bool | false | DF-corrected incremental Fock SCF. Same scope as df_guess, and warned and ignored on rhf/ksdft. | |
df_increments_aux | string | def2-universal-jkfit | aux basis name | It is an error when df_increments is off. |
check_stability | bool | false | Diagnostic 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_descent | bool | false | State 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. | |
ladder | array of tables | built-in ladder | see 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
guess | string | "minao" | minao sad hcore | As [scf] guess: "sad" is an alias of "minao", and any other value (including sad-smallbasis) is an error. |
level_shift | float | inherits | ||
max_iter | integer | inherits | ||
df_j_aux, df_k_aux | string | inherits | ||
stall_window | integer | none | ||
divergence_tol | float | none | ||
restart | bool | false | true discards the incoming density. |
[dft]
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
functional | string | "LDA" (for ksdft) | an XC name (LDA, PBE, B3LYP, wB97X-V, SCAN, r2SCAN, …) or a libxc name | Read by ksdft. wb97x-l-v ignores it with a warning. RPA/GW use [rpa] xc and TDDFT uses [tddft] xc instead. |
grid_prune | string | "none" | none off flat; nwchem nwchem-like nwchem_like | Prunes the main grid only. Accepted only with task = "energy". |
grid_radial | integer | 75 | > 0 | Radial 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_angular | integer | 110 | 6 14 26 50 110 302 434 590 | Lebedev order on the main grid. Same scope as grid_radial. An unsupported order is an error. Same as Python grid_angular=. |
dispersion | string | absent | d3bj, 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. |
lambda | float | 0.6 | Only for wb97x-l-v. | |
omega | float | 0.1 | Bohr⁻¹ | 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
auxbasis | string | "cc-pvdz-ri"; "cc-pvdz-rifit" for tda/tddft | aux basis name | The two defaults name the same bundled set (cc-pvdz-rifit is an alias of cc-pvdz-ri). |
frozen_core | int, string or bool | 0 | integer ≥ 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. |
omega | float | 0.420 | Å⁻¹ | att-rimp2 (erfc), rs-mp2-rpa. Ignored with a warning when attenuator = "terf". An error on att-rimp2 with att_operator = "terfc". |
kappa | float | none | κ > 0, Hartree⁻¹ | κ-regularized MP2 for the exact rimp2; an error with [local]. Absent = plain MP2. |
c_os | float | 1.2 (scs-mp2), 1.27 (scs-mp2-2terfc), 1.3 (laplace-sos-mp2) | ||
c_ss | float | 1/3 (scs-mp2), 4.05 (scs-mp2-2terfc) | laplace-sos-mp2 warns and ignores it. | |
n_quad | integer | 7 | 3 5 7 | laplace-mp2, laplace-sos-mp2. Any other value is an error. |
sos_formulation | string | "mo" | mo ao ao-sparse | laplace-sos-mp2. mo and ao are exact and agree to round-off. ao-sparse is approximate and requires domain_cutoff_bohr. |
domain_cutoff_bohr | float | none | > 0, Bohr | Required by ao-sparse. An error with the other formulations. |
formulation | string | "delta-lr" | delta-lr coupled-rings | rs-mp2-rpa. |
attenuator | string | "erf" | erf terf | rs-mp2-rpa. terf needs FERRIC_TERF_TABLE_DIR. An error on att-rimp2 (use att_operator). |
r0 | float | 1.6828 (= 3.18 Bohr) | Å | rs-mp2-rpa with terf only. An error on att-rimp2 (use att_r0). |
terf_omega | float | linked, ω = 1/(r0√2) | Å⁻¹, > 0 | rs-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_operator | string | "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_r0 | float | 1.05 | Å, > 0 | att-rimp2 with att_operator = "terfc" only; an error with erfc. |
r0_sweep | array of floats | none | Å, > 0 | rs-mp2-rpa with terf only. Reuses one SCF for several r0 values. r0 is then ignored with a warning. |
r0_bonded | float | 0.75 | Å | scs-mp2-2terfc. |
r0_nonbonded | float | 1.05 | Å, > r0_bonded | scs-mp2-2terfc. |
linlccd_variant | string | "hh" | hh drivers-only full | linlccd 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_r0 | float | 1.00 | Å, > 0 | mp2-v. Also sets the VV10 damping r0. It is correlated with mp2v_b in the published fit. |
mp2v_b | float | 11.0 | mp2-v. | |
mp2v_c | float | 0.0089 | mp2-v. Fixed in the paper. Changing it leaves the published parameterization. | |
mp2v_attenuator | string | "terfc" | terfc erfc | mp2-v. terfc needs FERRIC_TERF_TABLE_DIR. erfc is an unparameterized control. |
mp2v_omega | float | linked, ω = 1/(r0√2) | Å⁻¹, > 0 | mp2-v with terfc only. Setting it leaves the fitted parameterization. |
mp2v_vv10_damping | string | "terfc" | terfc none | mp2-v. none double-counts short-range correlation. |
mp2v_nlc_n_radial | integer | 50 | > 0 | mp2-v VV10 grid. |
mp2v_nlc_n_angular | integer | 50 | > 0 | mp2-v VV10 grid (unpruned). |
oo_max_iter | integer | 100 | ≥ 1 | oo-rimp2 only (closed and open shell). Orbital-optimization iterations. |
oo_grad_conv | float | 1e-4 | > 0 | oo-rimp2 only. Convergence threshold on the orbital-gradient norm. |
oo_level_shift | float | 0.1 | Hartree, ≥ 0 | oo-rimp2 only. Level shift on the approximate diagonal orbital Hessian. |
oo_diis_size | integer | 6 | ≥ 1 | oo-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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
scheme | string | "none" | none amplitude-threshold | amplitude-threshold: drop pair amplitudes whose localized integral is at or below eps (single threshold). Any other value is an error. |
eps | float | required with amplitude-threshold | ≥ 0, finite | The 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_sweep | array of floats | none | each ≥ 0 | drpa only. Several ε on one SCF and one localized assembly; sorted and de-duplicated, one result block per point. Instead of eps, not with it. |
reference | bool | false | Also 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_direct | bool | false | rimp2 only. The integral-direct local MP2: never forms the global 3-index tensor. | |
aux_radius | float | 10.0 | Bohr, > 0 | Integral-direct only (an error otherwise). Aux fit-domain radius. |
virt_radius | float | 12.0 | Bohr, > 0 | Integral-direct only. Virtual domain radius. |
ao_tail | float | 1e-3 | ≥ 0 | Integral-direct only. 0.0 keeps every shell. |
schwarz_skip | float | 1e-5 | ≥ 0 | Integral-direct only. Must be 0.0 for terfc operators, or the run errors. |
batch_merge | integer | 4 | ≥ 1 | Integral-direct only. |
gate_cal | float | none (gate off) | > 0 | Integral-direct only. Pair-gate calibration (~0.7 Coulomb, ~0.02 erfc ω = 1). |
virt_schwarz_kappa | float | none (off) | > 0 | Integral-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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
auxbasis | string | "cc-pvdz-ri" | aux basis name | |
frozen_core | int, string or bool | 0 | as [mp2] | |
xc | string | none (HF reference) | XC name | Switches 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_quad | integer | 20 (energy runs); 16 (task = "optimize") | The Python run_pdep_rpa uses 40. Set it explicitly for reproducibility. | |
quadrature | string | "gauss-legendre" | gauss-legendre gauss_legendre gl; minimax mini-max mm; chebyshev-tan chebyshev_tan chebyshev ct | |
u0 | float | 0.5 | Warn-and-ignore under minimax, which derives its own u₀. | |
trunc_thresh | float | 1e-4; 0.0 (full rank) for rs-mp2-rpa | PDEP truncation. | |
eigensolver_conv_thresh | float | 1e-6 (energy); 1e-8 (optimize) | Alias: davidson_conv_thresh. | |
chi0_sparsity | string | "dense" | dense, boys, boys:<thresh>, auto, auto:<cutoff>, auto:<cutoff>:<thresh>, each boys/auto form optionally suffixed @<radius_bohr> | |
run_diagnostics | bool | false | ||
export_eigpot_prefix | string | none | Writes <prefix>_eigpot_NNN.cube. | |
export_eigpot_count | integer | 10 | Capped at the number of eigenpotentials. | |
cube_spacing | float | 0.2 | Bohr | |
cube_margin | float | 4.0 | Bohr | |
export_npz | string | none | path | Turns on the NPZ property bundle. The compute_* keys below default to true only when this is set. |
compute_esp | bool | true | ESP at the nuclei. | |
compute_esp_surface | bool | false | ESP on a vdW shell. | |
esp_surface_vdw_scale | float | 1.4 | ||
esp_surface_n_angular | integer | 110 | Lebedev order | |
compute_polarizability | bool | true | alpha_tensor, the molecular static α. | |
compute_alpha_atomic | bool | true | alpha_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_field | bool | true | ||
compute_density_matrix | bool | true | ||
compute_dipole | bool | true | ||
compute_hirshfeld_charges | bool | true | Proatoms are free-atom SCF densities in the molecule's basis and SCF settings, as in Python's hirshfeld_charges. | |
compute_lowdin_charges | bool | true | ||
compute_mulliken_charges | bool | true | ||
compute_chelpg_charges | bool | true | ||
compute_resp_charges | bool | true | ||
compute_c6 | bool | true | With c6_source = "pdep", also exports alpha_ct_dynamic (nfreq, 3, 3): molecular α(iω) − Σ_A α^A(iω). | |
allow_partial_npz | bool | false | By default a bundle missing a requested property fails the run. | |
c6_source | string | "ts" | ts pdep mbd | |
c6_partition | string | hirshfeld for pdep, becke for ts/mbd | becke 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
method | string | "g0w0" | g0w0 cohsex evgw0 evgw (case-insensitive) | |
qp_mos | [lo, hi] | HOMO−2 … LUMO+2 | absolute MO indices, half-open | |
max_ev_iter | integer | 20 | evGW/evGW0 outer loop. | |
ev_conv_thresh | float | 1e-4 | Hartree | |
pade_npts | integer | 0 (= [rpa] n_quad) | ||
qp_newton_damp | float | 1.0 | ||
frozen_core | int, string or bool | falls back to [rpa] frozen_core | as [mp2] | Also overrides the PDEP frozen core, so W and Σ agree. |
scissor | float | 0.0 | Hartree | tdhf-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. |
reference | string | "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).
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
n_roots | integer | 3 | ||
xc | string | none (HF reference: CIS or TDHF) | XC name | Selects the reference functional and the f_xc kernel. Meta-GGA, VV10 and range-separated functionals are refused. |
c_hf | float | the functional's short-range exact-exchange fraction; 1.0 with no xc |
[optimize]
Read when task = "optimize".
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
max_steps | integer | 100 | ||
g_max_thresh | float | 4.5e-4 | Hartree/Bohr | |
g_rms_thresh | float | 3.0e-4 | Hartree/Bohr | |
e_conv | float | 1e-6 | Hartree | |
trust_radius | float | 0.1 | Initial step size. | |
coordinates | string | "cartesian" | cartesian cart; internal internals redundant-internal redundant_internal |
[frequencies]
Read when task = "frequencies".
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
hessian | string | "auto" | auto; analytic; fd finite-difference | auto 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. |
delta | float | 5e-3 | Bohr, finite and > 0 | Central-difference step, finite-difference Hessians only. Check the printed Hessian asymmetry: it is zero in exact arithmetic. |
[memory]
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
budget_gb | float | auto | finite and > 0 | Precedence: 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_gb | float | — | Deprecated alias. budget_gb wins if both are set. |
[output]
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
json | string or bool | <input-stem>.ferric.jsonl beside the input | a 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
pqr | string | required | path | Geometry in Å and charges in e. The element is read from the atom name. |
qm_indices | integer array | [] | zero-based | Use this, or qm_seeds + qm_radius_angstrom, but not both. |
qm_seeds | integer array | [] | zero-based | |
qm_radius_angstrom | float | none | Å, > 0 | Requires qm_seeds. |
link_bonds | array of [qm, mm] | [] | Required when the cut crosses a covalent bond. | |
boundary_scheme | string | "delete-host" | keep delete-host rc rcd | Setting 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.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
epsilon | float | — | finite and > 1 | Dielectric constant. Give exactly one of epsilon and solvent. |
solvent | string | — | 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-insensitive | Dielectric constants at 298 K. An unknown name is an error. |
lebedev_order | integer | 110 | 6 14 26 50 110 302 | Tesserae per atomic sphere. |
[cosmo]
Conductor-like implicit solvent, applied to every SCF variant. It cannot be
combined with [pcm].
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
epsilon | float | required when the section is present | finite and > 1 | Default in code is 78.39, but serde has no default for this key. |
radius_scale | float | 1.17 | > 0 | Multiplies Bondi radii. |
lebedev_order | integer | 110 | 6/14/26/50/110/302 | |
s_matrix_kind | string | "GaussianSmeared" | GaussianSmeared PointCharge | Serde variant names, case-sensitive. |
[external_potential]
Strict, like every other section: an unknown key here or inside a point charge is an error.
| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
point_charges | array of { q, x, y, z, width } | [] | q in e; x, y, z in Bohr; width in Bohr, finite and > 0 | Written 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] | none | atomic units | Uniform 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
| Quantity | Unit |
|---|---|
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_2c | Bohr⁻¹ / Bohr (raw, unlike the run_* drivers) |
QmmmSystem(..., coords_angstrom) | Ångström |
QmmmSystem.point_charges() | Bohr |
| Energies | Hartree |
| Gradients | Hartree/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):
| Kind | Names |
|---|---|
| Orbital | sto-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
| Name | Purpose | CLI |
|---|---|---|
Molecule | Geometry, charge and multiplicity. from_xyz, from_xyz_string, coords, coords_bohr, symbols, atomic_numbers, is_ghost, natoms, nelec, nuclear_repulsion, to_xyz_string. | [molecule] |
BasisSet | A Gaussian basis set, orbital or auxiliary. BasisSet.bundled(name). | [basis] |
SCF and DFT
| Name | Purpose | CLI |
|---|---|---|
run_rhf | Closed-shell RHF with the full SCF knob set, point charges, field and IEF-PCM solvent. | rhf |
run_uhf | Unrestricted HF; α/β counts come from the molecule's charge and multiplicity. | uhf |
run_rohf | Restricted open-shell HF (Guest–Saunders coupling); returns a UhfResult. | rohf |
run_dft | Closed-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_ksdft | Alias of run_dft. | ksdft |
d3bj_energy | Grimme D3(BJ) dispersion energy for a molecule and functional, in Hartree. | [dft] dispersion |
mbd_rsscs_energy | MBD@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_omega | IP-based (Baer/Kronik) tuning of an RSH functional's ω (Bohr⁻¹); closed-shell neutral plus doublet cation. | — |
dft_grid_point_count | Number 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). | — |
RhfResult | Result of run_rhf: energy, converged, iterations, density(), orbital_energies(), mo_coefficients(). | |
UhfResult | Result of run_uhf/run_rohf: α and β densities and orbital energies. | |
DftResult | Result 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
| Name | Purpose | CLI |
|---|---|---|
run_cdft | Constrained 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. | — |
CdftConstraint | One fragment constraint: atoms (0-based), target (a Becke electron population, not a net charge), kind = "charge" (Nα + Nβ) or "spin" (Nα − Nβ). | — |
cdft_coupling | Wu–Van Voorhis coupling H_ab between two converged single-"charge"-constraint states solved with the same geometry, basis, occupations and Hamiltonian. | — |
CdftResult | energy (without the constraint term), converged, scf_converged, lambdas, populations, targets, density_alpha(), density_beta(), weight_matrix(i). | |
CdftCouplingResult | h_ab (sign is a phase convention), s_ab, e_a, e_b. |
See Constrained DFT for a worked example.
Geometry, vibrations and reaction paths
| Name | Purpose | CLI |
|---|---|---|
run_optimize | RHF geometry optimization (basis by name). | task = "optimize" |
run_frequencies | Harmonic frequencies by finite differences of the analytic gradient; RHF/UHF/ROHF or their KS variants. | task = "frequencies" |
run_saddle | First-order saddle-point (transition-state) search by P-RFO; closed-shell. | — |
run_irc | Intrinsic reaction coordinate in both directions from a saddle; closed-shell. | — |
OptimizeResult | energy, converged, steps, energy_trace, mol(). | |
FrequencyResult | Frequencies in cm⁻¹ (negative = imaginary), normal modes, asymmetry diagnostic. | |
SaddleResult | Outcome of a P-RFO search: geometry (Å), n_imaginary, imaginary_mode, is_transition_state(). | |
IrcResult | Both directions of an IRC, plus the saddle they came from. | |
IrcBranch | One direction of an IRC walk. |
QM/MM
| Name | Purpose | CLI |
|---|---|---|
QmmmSystem | A QM/MM partition with link atoms and boundary-charge schemes (coordinates in Å). | [qmmm] |
MmTopology | Explicit-parameter AMBER-form MM force field; assigns no parameters itself. | — |
run_qmmm | Embedded SCF energy plus QM gradient, MM forces and full gradient. | [qmmm] (energy) |
run_optimize_qmmm | Optimize a QmmmSystem; QM atoms always move, MM atoms per move_mm. | — |
QmmmResult | energy, qm_gradient(), mm_forces() (forces, not gradients), full_gradient(). | |
QmmmOptimizeResult | The relaxed partition and its energy trajectory. |
See QM/MM for a worked example.
MP2 family
| Name | Purpose | CLI |
|---|---|---|
run_rimp2 | RI-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_rimp2 | Orbital-optimized RI-MP2 (level-shifted Newton + DIIS + Cayley rotation). Closed shell only; open-shell OO-RI-MP2 is CLI-only. | oo-rimp2 |
run_mp3 | MP3 on an RHF reference, with RI integrals. | mp3 |
run_attenuated_rimp2 | RI-MP2 with the erfc-attenuated operator; ω in Å⁻¹, default 0.420. | att-rimp2 |
run_terfc_rimp2 | RI-MP2 with the exact tempered-erfc operator at one cutoff r0 (Å); needs the terfc tables. | — |
run_scs_mp2 | Spin-component-scaled MP2 (defaults c_OS = 6/5, c_SS = 1/3). | scs-mp2 |
run_scs_mp2_2terfc | Dual-attenuated SCS-MP2(2terfc); needs the terfc tables. | scs-mp2-2terfc |
run_mp2_v | MP2-V: attenuated MP2 plus damped VV10 nonlocal correlation. | mp2-v |
run_double_hybrid | B2PLYP or DSD-PBEP86 double hybrid. | b2plyp, dsd-pbep86 |
run_laplace_mp2 | Laplace-transform RI-MP2 (default 7 quadrature points). | laplace-mp2 |
run_laplace_sos_mp2 | Laplace-transform SOS-MP2, E = c_os · E_OS; MO, AO or AO-sparse formulations. | laplace-sos-mp2 |
RiMp2Result | Result 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). | |
OoRiMp2Result | Result of run_oo_rimp2, with converged and grad_norm. | |
Mp3Result | e_hf, e_mp2, e_mp3, e_corr, e_total. | |
AttenuatedMp2Result | Attenuated MP2 total, correlation and spin components. | |
ScsMp2Result | Result of run_scs_mp2/run_scs_mp2_2terfc, with e_os/e_ss. | |
Mp2VResult | MP2-V total, attenuated MP2 part and VV10 part. | |
LaplaceMp2Result | Laplace MP2 total, correlation and spin components. | |
SosMp2Result | Scaled and unscaled OS energy, c_os, n_quad and formulation echoed back. | |
DoubleHybridResult | Result 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=isNone(exact;"none"is the same) or"amplitude-threshold"; any other value is an error.eps=is required withlocal="amplitude-threshold": the threshold is part of the model and has no default.eps=0reproduces the exact method.eps=withoutlocal=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 andNone. 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 mapsaux_radius,virt_radius(Bohr),ao_tail,schwarz_skip,batch_merge,gate_calandvirt_schwarz_kappa(the CLI names and defaults). They are errors withoutintegral_direct=True.kappais 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.
| Name | Purpose | CLI |
|---|---|---|
run_drpa | dRPA@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_scan | The local dRPA over a list of eps values, sharing one SCF and localization; each dict carries "local". | drpa + [local] eps_sweep |
run_linlccd | Linearized 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.
| Name | Purpose | CLI |
|---|---|---|
run_ccd | CCD correlation energy. | — |
run_ccsd | Spin-adapted closed-shell CCSD. | ccsd |
run_ccsd_t | CCSD plus the spin-adapted (T) correction. | — |
CcResult | correlation_energy and t_correction (None without triples). No reference energy. |
RPA, GW and excited states
| Name | Purpose | CLI |
|---|---|---|
run_pdep_rpa | Direct 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_rpa | Range-separated SR-MP2 + LR-RPA (formulation = "delta-lr" or "coupled-rings"; ω in Å⁻¹). | rs-mp2-rpa |
run_gw | Closed-shell G0W0 / COHSEX / evGW0 / evGW on an RHF or RKS reference. | gw |
run_u_gw | Open-shell GW variants on a UHF/UKS or ROHF reference. | gw with multiplicity > 1 |
run_bse_tda | BSE-TDA singlet excitation energies on a closed-shell RHF reference. | bse-tda |
run_tdhf_static_polarizability | RPAx@KS static (ω = 0) polarizability on a closed-shell KS reference. | tdhf-static-polarizability |
run_tddft | TDA or Casida excitations on a closed-shell HF (CIS/TDHF) or KS reference, with the f_xc kernel. | tda, tddft |
PdepRpaResult | total_energy, e_rpa, eigensolver_converged, eigenvalues and quadrature grid. | |
RsMp2RpaResult | SR-MP2, LR-MP2 and dRPA pieces; which fields are set depends on formulation. | |
GwResult | eps_qp, eps_mf, sigma_x, sigma_c, z_factor, outer_converged, qp_converged. | |
UGwResult | α and β versions of the GwResult fields. | |
BseResult | Excitation energies and oscillator strengths. | |
TdhfStaticPolarizabilityResult | Polarizability tensor and its isotropic value iso. | |
TddftResult | excitation_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.
| Name | Purpose | CLI |
|---|---|---|
esp_at_atoms | Electrostatic potential at each nucleus, in atomic units. | [rpa] compute_esp |
esp_at_points | Electrostatic potential at arbitrary points given in Bohr. | [rpa] compute_esp_surface (vdW-surface points only) |
mulliken_charges | Mulliken population charges. | [rpa] compute_mulliken_charges |
lowdin_charges | Löwdin (symmetric-orthogonalization) charges. | [rpa] compute_lowdin_charges |
hirshfeld_charges | Hirshfeld 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_charges | CHELPG charges fitted to the ESP on a grid. | [rpa] compute_chelpg_charges |
resp_charges | Single-stage RESP (restrained ESP-fit) charges. | [rpa] compute_resp_charges |
hirshfeld_polarizability | Per-atom Hirshfeld-partitioned static polarizability tensors (Bohr³) from PDEP-RPA. | — |
orbital_moments | Per-orbital centroids and spatial spreads (Bohr) of the restricted MOs. | — |
density_second_moment | 3×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.
| Name | Purpose | CLI |
|---|---|---|
compute_eri3 | Raw 3-centre Coulomb integrals (P|μν), shape (naux, n_bf, n_bf). | — |
compute_eri3_mo | MO-basis 3-centre integrals (P|pq) for any two coefficient matrices, built blockwise under a memory budget. | — |
compute_metric_2c | 2-centre metric (P|w|Q) over the auxiliary basis, Coulomb by default. | — |
shell_info | Shell centres (Bohr), first-function offsets and sizes, for building fitting domains. | — |
boys_localize | Foster–Boys localization of given orbitals; returns a BoysResult. | — |
BoysResult | Result of boys_localize: c_loc(), centers(), converged, iterations. |
Conformer ensembles
| Name | Purpose | CLI |
|---|---|---|
ConformerEnsemble | Conformers of one species sharing atom order, composition, charge and multiplicity. | — |
BoltzmannWeights | Boltzmann populations of an ensemble at one temperature. | — |
EnsembleDiagnostics | Population-structure readout: effective number of conformers, dominance verdict. | — |
WeightedStats | A weighted mean with its spread (mean, std_dev, min, max). | — |
boltzmann_weights | Boltzmann weights from a list of energies (Hartree) at a temperature (default 298.15 K). | — |
weighted_stats | Weighted mean and standard deviation of a scalar property. | — |
weighted_stats_vector | The same, component-wise, for a vector property. | — |
weighted_stats_tensor | The 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/:
| Example | Test | What the test asserts |
|---|---|---|
water-rhf.toml | epistemic_warning.rs, verbose_trace.rs, qmmm_reports_the_pqr_as_its_geometry.rs | The run completes and prints no [warning] grade line. |
water-tdhf-static-alpha.toml | epistemic_warning.rs | The Smoke-grade warning goes to stderr and not to stdout. |
water-qmmm.toml | qmmm_reports_the_pqr_as_its_geometry.rs | The 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.tomland anyrs-mp2-rparun withattenuator = "terf"need the tempered-erfc interpolation tables: pointFERRIC_TERF_TABLE_DIRat them. Without it these runs stop with an error.benzene-dfb3lyp-mpi.tomlis an ordinary input; its header gives the--features mpibuild and thempirunlaunch line.
SCF and DFT
See SCF and DFT.
| File | System / basis | kind / task | Reference in header | Notes |
|---|---|---|---|---|
water-rhf.toml | H2O / STO-3G | rhf / energy | — | water-qmmm.toml's header gives this geometry's energy as −74.9631468000. The file is commented to demonstrate screening. |
benzene-rhf.toml | benzene / cc-pVDZ | rhf | — | Exact 4-index J/K. |
benzene-rhf-dfj.toml | benzene / cc-pVDZ | rhf | — | RI-J only (cc-pvdz-ri). |
benzene-rhf-rijk.toml | benzene / cc-pVDZ | rhf | — | RI-JK (def2-universal-jkfit). |
benzene-rhf-def2.toml | benzene / def2-SVP | rhf | — | |
benzene-rhf-def2-rijk.toml | benzene / def2-SVP | rhf | — | RI-JK. |
decane-rhf.toml | decane / STO-3G | rhf | — | k_builder = "link". |
water-rhf-cosx.toml | H2O / cc-pVDZ | rhf | "E(COSX) − E(direct) is reported in crates/ferric-scf/tests/cosx_scf.rs" | COSX exchange. |
h_uhf.toml | H atom / STO-3G, doublet | uhf | — | |
h2_opt.toml | stretched H2 / STO-3G | rhf / optimize | — | |
water-frequencies.toml | H2O / STO-3G | rhf / frequencies | — | FD Hessian. Check Hessian asymmetry. |
benzene-dfb3lyp.toml | benzene / def2-SVP | ksdft B3LYP | — | RI-JK is on automatically for ksdft. |
benzene-dfb3lyp-mpi.toml | benzene / cc-pVDZ | ksdft B3LYP | — | Run under mpirun with the MPI build (see its header). |
water-wb97xv.toml | H2O / cc-pVDZ | ksdft wB97X-V | — | |
water-pbe-d3bj.toml | H2O / cc-pVDZ | ksdft PBE + D3(BJ) | — | Prints E(KS-DFT) and E(D3BJ) separately. |
water-pbe-mbd.toml | H2O / cc-pVDZ | ksdft PBE + MBD@rsSCS | — | Prints E(KS-DFT) and E(MBD@rsSCS) separately. |
water-pbe-pruned-grid.toml | H2O / cc-pVDZ | ksdft 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.toml | H2 / STO-3G | ksdft LDA / optimize | — | |
water-qmmm.toml | H2O + Na⁺ (PQR) / STO-3G | rhf + [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.toml | H2O / STO-3G | rhf + [pcm] | — | IEF-PCM water (solvent = "water", ε = 78.4). |
water-rhf-smeared-charge.toml | H2O / STO-3G | rhf + [external_potential] | — | One Gaussian-smeared charge (width, Bohr) and one point charge. |
o2-uhf-stability-descent.toml | O2 triplet / STO-3G | uhf | "the descent follows the downhill eigenvector to the UHF minimum (-147.63530 Ha)" | [scf] stability_descent = true. |
MP2 family
See The MP2 family.
| File | System / basis | kind | Reference in header | Notes |
|---|---|---|---|---|
water-rimp2.toml | H2O / cc-pVDZ | rimp2 | — | All-electron. |
water-rimp2-frozen-core.toml | H2O / cc-pVDZ | rimp2 | Expected log line: "[ferric] frozen core: 1 orbital(s) frozen from [mp2] frozen_core = "auto"" | |
water-rimp2-local.toml | H2O / 6-31G | rimp2 + [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.toml | octane / 6-31G | rimp2 + [local] | — | Integral-direct local MP2 (integral_direct = true), every locality map at its default. |
water-drpa.toml | H2O / 6-31G | drpa | — | Exact dRPA (no [local]). |
water-drpa-local.toml | H2O / 6-31G | drpa + [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.toml | H2O / cc-pVDZ | mp3 | — | |
water-oo-rimp2.toml | H2O / cc-pVDZ | oo-rimp2 | — | Proven (narrow) grade. |
water-attmp2.toml | H2O / aug-cc-pVDZ | att-rimp2 | — | ω = 0.420 Å⁻¹. |
water-attmp2-terfc.toml | H2O / aug-cc-pVDZ | att-rimp2 | — | att_operator = "terfc", att_r0 = 1.05 Å. Needs the terf tables. |
water-scs-mp2.toml | H2O / cc-pVDZ | scs-mp2 | — | Grimme coefficients (defaults). |
water-scs-mp2-2terfc.toml | H2O / cc-pVDZ | scs-mp2-2terfc | — | Thesis defaults r0 = 0.75/1.05 Å. Needs the terf tables. |
water-laplace-rimp2.toml | H2O / cc-pVDZ | laplace-mp2 | — | |
water-laplace-sos-mp2.toml | H2O / cc-pVDZ | laplace-sos-mp2 | — | |
water-mp2v.toml | H2O / aug-cc-pVDZ | mp2-v | — | The header warns that aDZ is outside the fitted basis. Needs the terf tables. |
water-rs-mp2-rpa.toml | H2O / aug-cc-pVDZ | rs-mp2-rpa | — | Smoke grade. |
Coupled cluster and double hybrids
See Coupled cluster and RPA and GW § double hybrids.
| File | System / basis | kind | Reference in header | Notes |
|---|---|---|---|---|
water-ccsd.toml | H2O / cc-pVDZ | ccsd | "PySCF CCSD run on the same density-fitted integrals gives E_corr = -0.2135061893 Ha" | All electrons, aux cc-pvdz-ri. |
water-linlccd.toml | H2O / 6-31G | linlccd | — | Exact LinLCCD(hh). |
water-linlccd-local.toml | H2O / 6-31G | linlccd + [local] | — | Amplitude-threshold LinLCCD(hh), eps = 1e-4. |
water-ccd.toml | H2O / STO-3G | ccd | — | |
water-ccsd-t.toml | H2O / STO-3G | ccsd(t) | — | Prints the CCSD correlation energy, the (T) correction and the total. |
water-wb97xlv.toml | H2O / 6-31G | wb97x-l-v | — | λ = 0.6, ω = 0.1 Bohr⁻¹ (published values). Proven (narrow) grade. |
water-b2plyp.toml | H2O / cc-pVDZ | b2plyp | — | Spike grade. Aux cc-pvdz-rifit (an alias of cc-pvdz-ri). |
RPA, C6 and properties
See RPA and GW.
| File | System / basis | kind | Reference in header | Notes |
|---|---|---|---|---|
water-pdep-rpa.toml | H2O / cc-pVDZ | pdep-rpa | — | Writes eigenpotential cube files. |
h2o-pdep-rpa-props.toml | H2O / cc-pVDZ | pdep-rpa | — | NPZ export. |
benzene-pdep-rpa.toml | benzene / def2-SVP | pdep-rpa | — | RI-JK SCF. |
benzene-pdep-rpa-export.toml | benzene / cc-pVDZ | pdep-rpa | — | Exports cube files. |
benzene-rijk-pdep-rpa.toml | benzene / cc-pVDZ | pdep-rpa | — | Full rank (trunc_thresh = 0). |
water-c6-pdep.toml | H2O / aug-cc-pVTZ | pdep-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.toml | Ar / aug-cc-pVTZ | pdep-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.toml | H2O / cc-pVDZ | tdhf-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
| File | System / basis | kind | Reference in header | Notes |
|---|---|---|---|---|
water-g0w0-pbe.toml | H2O / cc-pVDZ | gw 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.toml | OH doublet / cc-pVDZ | gw 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.toml | OH doublet / cc-pVDZ | gw U-G0W0@ROHF | — | [gw] reference = "rohf". |
water-bse-tda.toml | H2O / cc-pVDZ | bse-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.toml | H2O / aug-cc-pVDZ | bse-tda | Literature: "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.toml | H2CO / cc-pVDZ | bse-tda | — | Pilot run. The lowest state is dark (f ≈ 0). |
c2h4-bse-tda.toml | C2H4 / cc-pVDZ | bse-tda | — | Pilot run. |
formaldehyde-bse-tda-augdz.toml | H2CO (QUESTDB geometry) / aug-cc-pVDZ | bse-tda | Literature: "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.toml | QUESTDB Thiel-set molecules / aug-cc-pVDZ | bse-tda | — | Same setup as the formaldehyde file. The reference values are in testdata/reference/thiel_set_subset.json. |
TDDFT
| File | System / basis | kind | Reference in header | Notes |
|---|---|---|---|---|
water-tda.toml | H2O / cc-pVDZ | tda (CIS, 5 roots) | — | Default aux cc-pvdz-rifit (an alias of cc-pvdz-ri). |
water-tddft-pbe.toml | H2O / cc-pVDZ | tddft @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:
kind | rung 0 | rungs 1–4 |
|---|---|---|
rhf | 60 | 60 / 60 / 80 / 100 |
ksdft | [scf] max_iter | 60 / 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.
| field | meaning |
|---|---|
kind | the method that produced it, e.g. "rimp2", "ccsd", "gw" |
total | the headline number a user would quote |
components | the 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:
| Crate | Start at |
|---|---|
ferric_core | Molecule, BasisSet, Shell |
ferric_scf | solve_rhf, solve_uhf, solve_rohf; constrained DFT in cdft_driver::solve_cdft_uhf and cdft_coupling::coupling_hab (Python: run_cdft, cdft_coupling) |
ferric_mp2 | ri_mp2, oo_ri_mp2 |
ferric_cc | ccsd_closed_shell, ccsd_t_closed_shell |
ferric_rpa | pdep_polarizability_static, RPA correlation drivers |
ferric_gw | run_gw, run_evgw |
ferric_dft | KsXc, functional construction via libxc |
ferric_tensors | the 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 case | entry point | cost (measured) | plot |
|---|---|---|---|
| docking | docking.vina_dock, tiers.tier1_dock | 31 s @ 57 atoms, 5.7 @ 21, 1.9 @ 9 (ex=4, 7LCJ); ~N^1.5 | pose_ensemble, funnel_survival |
| docking geom opt | active_site.pose_relaxation | 77.8 s/step @ 71 atoms in a 6458-charge pocket | optimization_trace |
| minima with FF | tiers.tier2_forcefield | 9 ms @ 21 atoms (2.2 ms @ 9 atoms, 8.2 @ 19, 21.6 @ 34) | tier_comparison |
| minima with xtb | tiers.tier3_gfn2 | 39 ms @ 21 atoms (0.152 s @ 9, 0.050 @ 19) | tier_comparison |
| score with DFT | tiers.tier4_dft | 2.6 s @ 9 atoms at the def2-svp DEFAULT (0.75 s at STO-3G) | tier_comparison |
| transition state | ferric.run_saddle | 2*(6N+1) + (n_steps+1) gradients | imaginary_mode |
| reaction path (IRC) | ferric.run_irc | ~70 gradients/branch | reaction_path |
| common substitutions | pipeline.substitution | 7.6 ms warm / 7 proposals (248 ms first call) | site_substituent_heatmap |
| toxicology | tox.alerts, tox.assess | 3.7 ms screen; 54 ms offline assess, 1.6 s with the default include_web=True | liability_profile |
| binding energy in site | active_site.binding_energy | 137 s @ 71 atoms / 6458 charges (TWO SCFs + pdb2pqr) | pocket_polarization |
| QM/MM setup | ferric.QmmmSystem | free | qmmm_partition |
| dispersion D3(BJ) | run_dft(dispersion="d3bj") | microseconds, energy and gradient | folded 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:
| basis | total | dock | FF | xtb | DFT |
|---|---|---|---|---|---|
| STO-3G | 45.0 s | 72.7% | 0.1% | 0.3% | 27.0% |
def2-svp (the tier4_dft DEFAULT) | 82.1 s | 39.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:
-
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.
-
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.
-
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
- libint2: Gaussian integral engine
- libxc: exchange–correlation functionals
- pyo3: Rust/Python interop
- ndarray and ndarray-linalg: arrays and LAPACK bindings
- D3 reference tables are generated from simple-dftd3 (LGPL-3.0-or-later)
License
Dual-licensed under either
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT License (LICENSE-MIT)
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 written | status |
|---|---|
context["geometry"] never written -> docked pose discarded | FIXED (#93). _harvest_geometry present in funnel.py |
| No structure readers -- xyz only | FIXED (#91, gro in #128). tools/structure reads PDB/mmCIF/PQR/SDF/mol2/gro/SMILES |
normal_modes not exposed to Python -> C4 uncompletable | FIXED (#97). Present in the bindings |
| No substitution enumerator wired to the pocket | FIXED (#96). propose_substitutions + embed_proposals |
| Gradient memory 3.033 GB peak | FIXED (#92). 7.4x lower |
| MPI job flakes ~7% | FIXED (#98). Launch retried once |
| No dispersion at all | FIXED (#99). Native D3(BJ), verified 2e-16 Ha |
| Correlated energies never reach a machine-readable log | FIXED (#100). Each method's own total goes in a result record |
build-and-test is a 63-min critical path | FIXED (#101). 4-way shard, 2466 tests, 8.3% spread |
| danuglipron flow exists only as scratch scripts | FIXED (#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_ircwalks 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 closurefind_saddletakes, 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_gradientis 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"andwith_gradient=Truenow 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"andrun_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.pykeys 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 to | call | cost | the plot that answers it |
|---|---|---|---|
| enumerate substitutions | substitution.propose_substitutions | 7.6 ms / 7 proposals warm (248 ms first call) | site_substituent_heatmap |
| screen toxicology | tox.alerts.RdkitAlertsProvider.fetch | 4.9 ms / molecule, 13 endpoints | liability_profile |
| rank analogues by liability | tox.assess.assess_smiles -> .liability_score | 54 ms offline; 1.6 s with the default include_web=True | liability_profile |
| dock a ligand | docking.vina_dock | 31 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_forcefield | 9 ms @ 21 atoms (2.2 @ 9, 8.2 @ 19, 21.6 @ 34) | tier_comparison |
| relax a pose (xtb) | tiers.tier3_gfn2 | 39 ms @ 21 atoms (0.152 s @ 9, 0.050 @ 19) | tier_comparison |
| score with DFT | tiers.tier4_dft | 2.6 s @ 9 atoms at the def2-svp DEFAULT (0.75 s at STO-3G; 613 s @ 71) | tier_comparison |
| find a transition state | ferric.run_saddle | 2*(6N+1) + (n_steps+1) gradients | energy_profile |
| confirm it is one | ferric.run_frequencies | 6N+1 gradients | imaginary_mode |
| get the barrier | ferric.run_irc | ~70 gradients / branch | reaction_path |
| bind in a pocket | active_site.binding_energy | 137 s @ 71 atoms/6458 charges, STO-3G (TWO SCFs + pdb2pqr) | pocket_polarization, site_substituent_heatmap |
| relax a geometry (QM) | ferric.run_optimize | 1 gradient/step; 6 steps for an embedded methyl | optimization_trace |
| set up a QM/MM cut | ferric.QmmmSystem + .with_boundary_charges | free (setup) | qmmm_partition |
| draw the molecule | viz.molecules.depict | 5.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:
| ligand | atoms | dock time | Vina score |
|---|---|---|---|
| ethanol | 9 | 1.9 s | -2.353 |
| aspirin | 21 | 5.7 s (n=4, 5.5-6.0) | -6.572 |
| drug-scale | 57 | 31.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)].
| funnel | what tier 4 received |
|---|---|
origin/main (no fix) | 15 ETKDG coordinates -- a freshly embedded, free-solution geometry |
| with PR #93 | exactly [(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.
| format | reader | layer | notes |
|---|---|---|---|
| xyz | Molecule::load_xyz, parse_xyz | Rust | the only native format |
| xyz (multi-frame) | ConformerEnsemble.from_multi_xyz | Rust | from_xyz reads ONLY frame 1 |
| PDB / mmCIF | tools/structure (gemmi) | Python | gemmi is a CORE dep |
| PQR | tools/active_site/pqr_parser, tools/structure | Python | charges are MM, not a QM charge state |
| SDF / mol / mol2 | tools/structure (rdkit) | Python | ferric[docking] extra |
| SMILES | tools/structure.from_smiles (rdkit ETKDG) | Python | geometry is tier-2 grade, NOT optimized |
| GROMACS gro | tools/structure (builtin) | Python | nm -> A; frame 1 only; cross-checked vs OpenMM |
| AMBER prmtop | via OpenMM only | Python | no direct reader |
| OpenMM | active_site/mm_topology.topology_from_openmm | Python |
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 path | cost | notes |
|---|---|---|
xyz -> Molecule (71 atoms) | 0.3 ms | the native path; free |
SMILES -> 3-D (from_smiles, ETKDG+MMFF) | 8.5 ms | per molecule |
PDB -> PocketCharges (derive_pocket_charges, 7LCJ pocket) | 2.66 s | 6458 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 path | cost |
|---|---|
xyz -> Molecule (21 atoms) | 0.10 ms |
pdb (WITH hydrogens) -> Molecule | 0.11 ms |
mol2 -> Molecule | 0.12 ms |
multi-frame xyz -> 20-frame ConformerEnsemble | 0.23 ms |
sdf -> Molecule | 0.33 ms |
| SMILES -> 3-D | 8.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.
| dependency | status | used by |
|---|---|---|
pdb2pqr30 3.7.1 | on PATH | active_site/pdb2pqr_runner (PDB -> pocket charges) |
openmm | importable | active_site/mm_topology |
vina | importable | tools/docking tier 1 |
meeko | importable | ligand prep for Vina |
rdkit | importable | SMILES/SDF/mol2, ETKDG, tools/isomers |
gemmi | importable (core dep) | PDB/mmCIF via tools/structure |
xtb | see [[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 question | method | cost | resolution it delivers | verdict |
|---|---|---|---|---|
| where does this ligand sit? | Vina dock | ~2 min/ligand @ ex=32, ~30 s @ ex=4 | 0.95 A redock (M9), 20/20 poses on-site | use it |
| which pose is best? | Vina score | free (comes with the dock) | r(score, RMSD) = +0.461; only 4/20 under 2.0 A | do not trust -- generates, cannot rank |
| is this geometry sane? | MMFF94 | 2-22 ms/pose (9-34 atoms), ~73 ms @ 71 | adequate to declash | use it, for declashing only |
| how strained is this conformer? | GFN2-xTB | 0.05-0.152 s/pose (9-19 atoms) | 143 kcal/mol anion/neutral split resolved | use it for coarse separation |
| which of these conformers is lowest? | GFN2-xTB | 0.05-0.152 s/pose | Spearman 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-DFT | 96 s @ 32 atoms, 612 s @ 71 | 1e-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 it | use it -- full pocket, or validate your cut |
| where is the transition state? | saddle::find_saddle | 2*(6N+1) + (n_steps+1) gradients | converges on a known saddle; refuses a minimum's basin | use it |
| is this really a TS? | harmonic_frequencies | 6N+1 gradients | exactly-one-imaginary check, from Rust AND Python | use it |
| is dispersion missing from my DFT? | [dft] dispersion = "d3bj" | microseconds, energy AND gradient | two-body D3(BJ), Z=1-103, vs simple-dftd3 | use it -- semilocal DFT has no London dispersion at all |
| ...and optimize on that surface? | same, task = "optimize" | same | Ar2 6.7917 -> 6.6418 Bohr (attraction shortens the bond) | use it |
| ...and get frequencies on it? | -- | -- | the FD Hessian from the D3 gradient is unvalidated | refused, 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 band | use it |
| is this molecule a liability? | tools/tox alerts | 9.4 ms/molecule | published alert sets, NOT a probability of harm | use 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 charges | wall | vs vacuum |
|---|---|---|
| 0 | 0.29 s | 1.00x |
| 10 | 0.18 s | 0.62x |
| 100 | 0.18 s | 0.64x |
| 1000 | 0.31 s | 1.06x |
| 6458 | 0.97 s | 3.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:
| path | coordinate frame | safe to embed? |
|---|---|---|
dock_ligand -> DockedPose.coords_angstrom | the receptor's (its docstring says so) | yes |
propose_substitutions -> embed_proposals | origin-centred ETKDG | no -- 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:
| tier | on 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) | kept | dE (kcal/mol) | err vs full | net charge kept |
|---|---|---|---|---|
| 6 | 62 | -1.070 | +1.815 | +0.845 e |
| 8 | 174 | -3.344 | -0.459 | +0.136 |
| 10 | 337 | -2.849 | +0.036 | +3.552 |
| 12 | 647 | -2.821 | +0.064 | +0.023 |
| 15 | 1280 | -1.967 | +0.918 | +1.699 |
| 20 | 2466 | -2.130 | +0.755 | +2.051 |
| 30 | 4025 | -2.005 | +0.880 | +1.367 |
| full | 6458 | -2.885 | 0 | -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.
| functional | grade | worst error vs PySCF | reach for it when |
|---|---|---|---|
| PBE | Proven (narrow) | 2.1e-8 Ha | the default. Cheapest of the proven set, and the tightest agreement. |
| B3LYP | Proven (narrow) | 1.6e-8 Ha | a hybrid is wanted for barriers or charge transfer; ~exact-exchange cost over PBE. |
| LDA | Proven (narrow) | 5.9e-6 Ha | essentially never for chemistry -- 300x looser than PBE and it overbinds. Useful as a cheap smoke test. |
| wB97X-V | Proven (narrow) | 3.1e-5 Ha | range separation matters (long-range CT, some excited states). Note this is the LOOSEST of the four, 1500x PBE. |
| SCAN / r2SCAN | Proven (narrow) | 1.95e-8 Ha | meta-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_xcNewton 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:
| molecule | atoms | STO-3G | def2-SVP | ratio |
|---|---|---|---|---|
| methanol | 6 | 0.03 s | 0.15 s | 4.4x |
| ethanol | 9 | 0.05 s | 0.54 s | 11.1x |
| benzene | 12 | 0.17 s | 2.39 s | 14.0x |
| aspirin | 21 | 2.35 s | 23.72 s | 10.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)
| tier | method | cost | source |
|---|---|---|---|
| 1 | Vina, exhaustiveness 4 (the RECOMMENDED setting) | 26.4 s/ligand @ cpu=0 (12 cores); 109.0 s @ cpu=1 | RESULTS.md M11 |
| 1 | superseded by the ex=4 row above; the figure was never measured and its tiers.py:11 citation pointed at the module doc comment | ||
| 1 | Vina, per pose | ~10 us | tools/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:
| route | exponent | fixed basis? |
|---|---|---|
| the two rows above | 2.32 | NO (def2-SVP vs STO-3G) |
| PBE/STO-3G N-sweep (this) | 2.32 | yes |
| RHF/STO-3G N-sweep | 2.58 | yes |
3->9 atoms via tier4_dft (2026-09-20) | 2.16 overall, 2.38 on the 6->9 tail | yes |
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.7xThe 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:
system atoms vacuum embedded ratio ABSOLUTE overhead benzene 12 0.278 s 1.574 s 5.7x 1.30 s danuglipron 71 43.1 s 49.1 s 1.14x 6.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):
| tier | 9 atoms | 19 atoms | 34 atoms |
|---|---|---|---|
2 tier2_forcefield | 2.2 ms | 8.2 ms | 21.6 ms |
3 tier3_gfn2 | 0.152 s | 0.050 s | -- |
4 tier4_dft (STO-3G) | 0.66 s | 8.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.
| molecule | atoms | tier 2 |
|---|---|---|
| ethanol | 9 | 2.2 ms |
| acetanilide | 19 | 8.2 ms |
| drug-like | 34 | 21.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:
| molecule | N | xtb | DFT | ratio |
|---|---|---|---|---|
| ethane | 8 | 0.024 s | 0.397 s | 16x |
| butane | 14 | 0.044 s | 1.374 s | 31x |
| hexane | 20 | 0.077 s | 3.619 s | 47x |
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:
| N | projected ratio |
|---|---|
| 32 | 81x |
| 71 (danuglipron) | 206x |
| 100 | 307x |
| ~274 | 1000x |
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:
| stage | cost | notes |
|---|---|---|
enumerate (propose_substitutions) | 2.8 ms / proposal | 207 ms for all 73 |
| relative descriptors | 1.7 ms / proposal | the P2 gate |
embed (embed_proposals, ETKDG+MMFF) | 214 ms / proposal | ~75x the other two combined |
toxicology alerts (RdkitAlertsProvider.fetch) | 9.4 ms / molecule | 13 endpoints |
| tox catalog construction | 47 ms, ONE-OFF | build 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 analogues | cheap half | dock | xtb | DFT | total |
|---|---|---|---|---|---|
| 100 | 0.4 min | 0.6 h | 0.0 h | 0.2 h | 0.8 h |
| 1000 | 3.8 min | 5.6 h | 0.3 h | 1.7 h | 7.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:
| cheap | dock | xtb | DFT | total (N=1000) | |
|---|---|---|---|---|---|
| with the ~20 s estimate | 0.8% | 73% | 4% | 22% | 7.6 h |
| with MEASURED 26.4 s | 0.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):
| basis | total | dock | FF | xtb | DFT |
|---|---|---|---|---|---|
| STO-3G | 45.0 s | 72.7% (32.7 s) | 0.1% | 0.3% | 27.0% (12.1 s) |
| def2-svp (the DEFAULT) | 82.1 s | 39.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:
| cheap | dock | xtb | DFT | total | |
|---|---|---|---|---|---|
| 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):
| quantity | value | why |
|---|---|---|
| Hessians per search | 2 | one 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 step | 1 | |
| one Hessian | 6N | central 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 atoms | one 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 region | steps | gradients | projected (STO-3G) |
|---|---|---|---|
| N = 11 | 30 | 162 | 0.2 min |
| N = 20 | 30 | 270 | 1.4 min |
| N = 20 | 100 | 340 | 1.8 min |
| N = 40 | 30 | 510 | 15.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:
| variant | use 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 |
WithinRadiusWholeResidues | the 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):
| selection | QM atoms |
|---|---|
qm_indices = the whole ligand | 71 |
qm_radius_angstrom = 2.0 | 5 |
qm_radius_angstrom = 4.0 | 15 |
qm_radius_angstrom = 6.0 | refused -- 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.
| field | max|g_z| at the planar geometry | outcome |
|---|---|---|
| gas phase | ~1e-16 | converges, 5 steps, 1 imaginary |
| symmetric (-0.2, -0.2) | ~1e-17 | converges, 5 steps, 1 imaginary; E shifted 5.0e-4 Ha |
| antisymmetric (-0.2, +0.2) | 3.8e-3 | runs 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):
| basis | vacuum | embedded | ratio | iterations |
|---|---|---|---|---|
| STO-3G | 10.6 ms | 11.2 ms | 1.06x | 8 -> 9 |
| cc-pVDZ | 162.0 ms | 156.1 ms | 0.96x | 11 -> 11 |
| def2-SVP | 35.1 ms | 32.9 ms | 0.94x | 11 -> 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.
| charges | wall | ratio | SCF iters | ms per charge |
|---|---|---|---|---|
| 0 (vacuum) | 95.8 ms | 1.00x | 11 | -- |
| 8 | 91.1 ms | 0.95x | 11 | (noise) |
| 64 | 96.6 ms | 1.01x | 11 | 0.012 |
| 256 | 115.4 ms | 1.20x | 11 | 0.077 |
| 1024 | 176.0 ms | 1.84x | 11 | 0.078 |
| 4096 | 403.7 ms | 4.21x | 11 | 0.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 size | predicted | vs vacuum |
|---|---|---|
| 1,000 charges | 174 ms | 1.8x |
| 10,000 charges | 876 ms | 9.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):
| system | basis | nbf | SCF peak | gradient peak | ratio |
|---|---|---|---|---|---|
| water | cc-pVDZ | 24 | 0.000 GB | 0.103 GB | -- |
| benzene | 6-31G | 66 | 0.012 GB | 1.099 GB | 92x |
| benzene | cc-pVDZ | 114 | 0.035 GB | 1.860 GB | 53x |
| alkane_10 | 6-31G | 134 | 0.162 GB | 5.930 GB | 37x |
| alkane_16 | 6-31G | 212 | 0.503 GB | 13.879 GB | 27x |
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:
| system | atoms | counted |
|---|---|---|
| H2 | 2 | 12 |
| water | 3 | 18 |
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:
| system | N | counter | 6N | 6N+1 |
|---|---|---|---|---|
| H2 | 2 | 12 | 12 | 13 |
| water | 3 | 18 | 18 | 19 |
| NH3 | 4 | 24 | 24 | 25 |
| CH4 | 5 | 30 | 30 | 31 |
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:
| model | needs from the SCF | cost | status in ferric |
|---|---|---|---|
| D3(BJ) | geometry + Z only, not even a density | negligible | implemented (#99, merged): energy + analytic gradient; task="frequencies" still refused, the FD Hessian from it is unvalidated |
| D4 | geometry, Z, EEQ charges | negligible | none; reuses nothing ferric has |
| XDM | rho, grad-rho, tau, grad^2-rho on a grid + Hirshfeld weights | negligible vs the SCF | ~80% present, see below |
| VV10 | rho, grad-rho INSIDE the SCF | O(N_pts^2) pair sum | implemented, 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 needs | ferric has |
|---|---|
| tau (Eq. 48) | eval_tau_closed / eval_tau_uks |
| AO Hessians -> grad^2-rho | eval_basis_grad_hess_on_points |
| V_A = int r^3 w_A rho -- literally XDM Eq. 45 | atomic_effective_volumes_becke (properties.rs:478) |
| free-atom alpha, Z=1-54 | ts_free_atom |
| grad^2-rho assembled | ABSENT -- 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.
| functional | simple-dftd3 1.6.0 | ferric | delta |
|---|---|---|---|
| PBE | -3.594687655702e-4 | -3.594687655704e-4 | 2e-16 Ha |
| B3LYP | -5.738758352593e-4 | -5.738758352595e-4 | 2e-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_ABis interpolated by both atoms' COORDINATION NUMBERS, so moving atom X changesC6for 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).
- Ligand in. SMILES ->
tools.structure.from_smiles; a file ->tools.structure.read. State charge and multiplicity explicitly. - Receptor in. PDB ->
pdb2pqr(active_site/pdb2pqr_runner) to add hydrogens and assign MM charges. Do NOT feed a crystallographic PDB directly;tools.structurerefuses one with no hydrogens for this reason. - Tier 1, dock. ~2 min/ligand. The pose is a HYPOTHESIS -- run
redock_rmsdagainst a known bound pose on your target first, or you do not know the search works. - Harvest the pose into
context["geometry"]-- see section 0. Without this, everything below scores a gas-phase conformer. - 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).
- 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 attiers.py:196andtiers.py:249) when the pocket is charged or polar. DECISION: do you trust the number? See the pose-noise caveat below. - Catalyst / barrier work.
BLOCKEDAVAILABLE since 2026-09-19 --ferric.run_saddle->run_frequencies->run_irc, all three takingpoint_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. Usedelete-hostat 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:
| ligand | atoms | G1 dock | G1 relax (MMFF) | G2 xtb | G3 DFT (STO-3G) |
|---|---|---|---|---|---|
| ethanol | 9 | 26.4 s** | 130 ms* | 20 ms | 2.6 s |
| aspirin | 21 | 26.4 s** | 12 ms | 46 ms | 62.6 s |
| paracetamol-like | 34 | 26.4 s** | 23 ms | 76 ms | 265.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 100 | DFT 10 survivors | DFT share | |
|---|---|---|---|
| STO-3G (the table above) | 44.0 min | 44.2 min | 50% |
| def2-svp (the default) | 44.0 min | 442 min | 91% |
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:
| treatment | ddE noise | vs a 0.25 kcal/mol gap |
|---|---|---|
| more poses, averaged (M4/M5) | 4.07 | 16x |
| relax in field then average (M6) | ~4.1 | ~16x |
| dock then average (M12) | ~4.1 | ~16x |
| select the top-docked pose (M13) | 40.66 | 163x |
| a different scorer (M14) | 4.68 best tracking | 19x |
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 geometry | energy (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_dft | run_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: SUPERSEDED 2026-09-19. #97 exposed it.
FrequencyResult.normal_modes is NOT
exposed in the pyo3 bindings...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=2is 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_ircanswers 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, andIrcBranch::convergedreports 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.
MoveMmfreezes 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.rsruns 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_frequenciesalready threadsconfig.external_potentialinto the samerhf_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_qmmmrebuilds 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_qmmmshares 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, withis_transition_state()as a method soconvergedalone 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. - MM charges are FIXED (
-
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_modereturns 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
-
WireDONE 2026-09-19 --saddle::find_saddleto the QM/MM evaluator.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 (needsoptimize_qmmm's evaluator extracted) and lifting the fixed-MM-field approximation; see section 4. -
Harvest the docked pose intoDONE (#93) --context["geometry"]inrun_funnel's stage loop (section 0).funnel._harvest_geometrywrites the key. -
Fix the "DFT + dispersion" label.DONE (#99) --tier4_dftpassesdispersion="d3bj"(nativeferric-d3) by default, so the label matches what the tier computes. QM/MM dispersion is still absent (section on dispersion above). -
Decide the pose treatment (section 3) before any QM/MM adapter.
-
Add the tier 3.5 producer:
derive_pocket_charges->context. Both quantum tiers ALREADY consumecontext["point_charges"], so this is a producer, not a new capability. -
Fix
relax_pose_in_pocket's movable pocket: it builds its QmmmSystem from(q,x,y,z)with symbol"X"(pose_relaxation.py:188), butmove_mm != "none"needs anMmTopologyin the same atom order, andPocketChargescarries no element symbols.move_mm="within"/"all"type-checks and is unreachable in practice. -
Fix the drifted docstring:
qmmm.rs:26-34says "no Lennard-Jones QM-MM term", butqmmm_mm_terms(qmmm.rs:1578) computes one atqmmm.rs:1674-1704. The code is right; the comment is stale. -
Expose
FrequencyResult.normal_modesto Python. [DONE, #97:FrequencyResult.normal_modesis in the bindings, which is what lets a Python caller checkfind_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. -
Seed each finite-difference displacement from the undisplaced converged density. VERIFIED that
frequencies.rshas 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. -
Surface
tools/insite/src/SUMMARY.md. [DONE: the pipeline is published under "End-to-end applications", "Toxicity screening" and the "Project notebooks" section ofSUMMARY.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:
| case | all satisfied | broken features |
|---|---|---|
| PARENT (control) | True | — |
| acid -> methyl ester | False | acid_or_bioisostere |
| benzene | False | all four |
| ethanol | False | all 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:
| subst | dMW | dcLogP | dTPSA | verdict |
|---|---|---|---|---|
| CN | +25.0 | -0.13 | +23.8 | score it |
| OMe | +30.0 | +0.01 | +9.2 | marginal |
| F | +18.0 | +0.14 | 0.0 | deprioritize |
| Me | +14.0 | +0.31 | 0.0 | deprioritize |
| Cl | +34.4 | +0.65 | 0.0 | deprioritize |
| CF3 | +68.0 | +1.02 | 0.0 | deprioritize |
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
context["geometry"]is never written (PR #93 fixes it). Until then S4's pose is discarded and S6/S7 score a gas-phase conformer.- 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.
- 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.pykeys one row per MOLECULE and cannot express an ensemble — decide the pose treatment BEFORE writing the adapter. - No connector between
tools.isomersandtools.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 position | charges kept | filtered |
|---|---|---|
| origin (outside the pocket) | 6458 / 6458 | 0 |
| pocket centroid | 6449 / 6458 | 9 |
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:
| substituent | n sites | mean (kcal/mol) | spread across sites |
|---|---|---|---|
| CN | 3 | -4.77 | 11.81 |
| F | 3 | -4.32 | 10.26 |
| CF3 | 3 | -2.21 | 16.49 |
| Cl | 3 | -1.97 | 9.71 |
| parent | 1 | +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
-
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.
-
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.pykeys 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
- Pocket placement: dock each embedded proposal (
embed_proposalsalready gives the(symbols, coords)from its SMILES). - Connector to
batch_prescreen-- CHEAP (classical field, no SCF), so it can afford an ensemble and sidesteps constraint 2 entirely. - 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:
| route | result | verdict |
|---|---|---|
| more poses (M4/M5) | sd flat in n; SEM falls as 1/sqrt(n) but sd does not move | closed -- 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 distinct | closed -- ~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
-
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.
-
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_descriptorschanges 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.
- 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
-
One row per molecule is the WRONG shape. Averaging is the least-bad estimator (see above), so
funnel.pyneeds to express a pose ENSEMBLE -- and it keys one row per candidate (funnel.py:162). This is a real, open gap. -
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
| tier | method | s/pose | population | job |
|---|---|---|---|---|
| 1 | AutoDock Vina | 1e-5 | 1e5-1e6 | search pose space |
| 2 | MMFF94 | 1e-3 | 1e2-1e3 | relax, declash |
| 3 | GFN2-xTB | 5e-1 | 10-1e2 | rank survivors |
| 4 | ferric DFT + D3(BJ) | 6e+2 | 1-10 | final 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:
| protocol | ddE noise (kcal/mol) | vs the 0.25 gap |
|---|---|---|
| select one pose (M13) | 40.66 | 163x |
| average n = 100 (M5) | 4.07 | 16x |
| 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):scorer CV Spearman vs pose_fit vina_score 0.071 -0.202 (p=0.41) pose_fit (xtb) 0.315 reference 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.pycannot 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 averaged ddE noise vs the 0.25 kcal/mol gap 1 46.75 187x 15 12.07 48x 100 4.68 19x 1000 1.48 6x 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=-1on NEUTRAL structures, which asks for an electron that does not exist; now deprotonates the STRUCTURE (Isomer.deprotonated,tools/isomers/model.py:44) withtest_deprotonation_conserves_electron_countpinning 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/andtestdata/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.