Skip to content

Native Hartree-Fock

Before a molecule's electronic-structure problem can become a qubit Hamiltonian, its integrals (overlap, kinetic, nuclear-attraction, electron-repulsion) have to be computed and a Hartree-Fock mean-field calculation run to get a reference orbital basis. dense_evolution.native_hf is a from-scratch, JAX-vectorized engine for that step -- covers any element basis_set_exchange has STO-3G data for, not just the small H-Ne table PennyLane's own bundled solver ships.

pip install dense-evolution[hf]

The hf extra installs basis_set_exchange, which supplies the basis-set data. Step 1 also builds a PennyLane Hamiltonian, so it needs pip install dense-evolution[pennylane], which includes the same package.

Step 1. A real molecule's qubit Hamiltonian

import numpy as np
from dense_evolution.native_hf.bridge import build_qubit_hamiltonian

geometry = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.7414]])
H, n_qubits, hf_result = build_qubit_hamiltonian([1, 1], geometry, n_electrons=2)

n_qubits, hf_result.converged, hf_result.n_iterations, hf_result.total_energy
(4, True, 3, -1.116684335260025)

build_qubit_hamiltonian(atomic_numbers, geometry_angstrom, n_electrons) runs the full pipeline -- Obara-Saika integrals, then Hartree-Fock self-consistent-field iteration (run_scf, DIIS-accelerated) -- for H2 (two protons, atomic number 1, 0.7414 Angstrom apart, the real equilibrium bond length) in the default STO-3G minimal basis. It converged in 3 iterations to a mean-field energy of -1.1167 Ha. H is a qml.Hamiltonian -- the converged result still goes through PennyLane's own fermionic_observable + jordan_wigner for the qubit mapping, since that stage was already fast and well-tested; this module only replaces the slow integral/SCF stage.

Step 2. Hartree-Fock is a mean-field approximation -- how far off?

import dense_evolution as de

coeffs, ops = H.terms()
terms = []
for coeff, op in zip(coeffs, ops):
    pauli = {}
    for factor in (op.operands if hasattr(op, 'operands') else [op]):
        if factor.name != 'Identity':
            pauli[int(factor.wires[0])] = factor.name[-1]
    terms.append((float(np.real(complex(coeff))), pauli))

H_dense = de.pauli_hamiltonian_to_matrix(terms, n_qubits)
float(np.min(np.linalg.eigvalsh(H_dense)))
-1.1372701878105904

Converting H's Pauli terms to pauli_hamiltonian_to_matrix's format and exactly diagonalizing gives -1.1373 Ha -- lower than Step 1's Hartree-Fock energy by about 0.02 Ha, the real electron-correlation energy Hartree-Fock's single-Slater- determinant approximation misses entirely. At only 4 qubits, exact diagonalization is easy; that gap is exactly what a real VQE ansatz beyond a bare Hartree-Fock reference state is trying to close for larger, classically-intractable molecules.

Step 3. Large mixed-basis molecules: the optional libcint bridge

import numpy as np
from dense_evolution.native_hf.basis import build_molecule_shells
from dense_evolution.native_hf.assembly import build_overlap_matrix, build_core_hamiltonian
from dense_evolution.native_hf.libcint_bridge import build_repulsion_tensor_libcint
from dense_evolution.native_hf.scf import run_scf

geometry = np.array([[0.0, 0.0, 0.0]])
shells = build_molecule_shells([10], geometry, "6-31g*")
S = build_overlap_matrix(shells)
H_core = build_core_hamiltonian(shells, [10.0], geometry)
V = build_repulsion_tensor_libcint([10], geometry, "6-31g*")

result = run_scf(S, H_core, V, 10, [10.0], geometry)
result.converged, result.total_energy
(True, -128.4744065199...)

Step 1's molecule only needed s functions, so build_repulsion_tensor (the default, pure-JAX path) was already fast. A basis mixing s, p, and d shells -- like 6-31g* on neon here -- costs much more with the default path: the underlying JIT compiles one program per distinct shell-quartet shape it meets, and a mixed basis needs dozens of them. build_repulsion_tensor_libcint computes the identical tensor through libcint instead -- a mature C library with no per-basis compile cost at all -- and is a drop-in replacement everywhere build_repulsion_tensor's output was used (S and H_core above still come from native_hf itself). libcint ships inside the dense-evolution wheels for Windows, macOS and Linux, so pip install dense-evolution is enough. On neon in 6-31g* the tensor takes 0.013 s; on ethanol (57 basis functions) 0.32 s, against 0.50 s for PySCF on the same Linux machine.


Step 4. Diagnosing a stubborn SCF: energy_history

from dense_evolution.native_hf.scf import diagnose_convergence

result = run_scf(S, H_core, repulsion, n_electrons, nuclear_charges, geometry_bohr)
diagnosis = diagnose_convergence(result)
print(diagnosis["anomaly_fraction_hampel"])

run_scf already computes the electronic energy at every iteration -- HFResult.energy_history now keeps that trace instead of throwing it away (shape (max_iterations,), NaN past n_iterations). diagnose_convergence runs Dense-Armor's own Hampel filter and Tukey fences (dense_evolution.utility.robust_filters -- the sister project's anomaly detectors, validated with 0 false positives on real H2 dissociation-curve chemistry) over that trace, instead of trusting the final converged flag in isolation.

Measured, not assumed: a real 28-heavy-atom CASMI26 fragment (this module's own level_shift docstring) that fails to converge at level_shift=0.0 has its 200-iteration electronic-energy trace flagged 19-24% anomalous (Hampel/Tukey); the identical fragment, fixed with level_shift=0.5 (converges in 55 iterations), flags only ~4% -- background noise, not a false-alarm storm. Needs the armor extra (pip install dense-evolution[armor]); dense_armor is not a hard dependency of this module.

Step 5. Energy as a function of the atoms' positions

Step 1 gives the energy of one molecule at one fixed geometry. But the interesting questions are about change: how much does the energy rise if the two atoms are pulled apart? What force does each nucleus feel? Where is the equilibrium bond length? All of these need the energy as a function of the atomic positions, plus its gradient.

import numpy as np
from dense_evolution.native_hf.differentiable import build_energy_fn

geom = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.4011]])

energy_fn = build_energy_fn(
    atomic_numbers=[1, 1],
    nuclear_charges=[1.0, 1.0],
    n_electrons=2,
    basis_name="sto-3g",
    reference_geometry_bohr=geom,
)

energy_fn(geom)
-1.1166827344469228

geom is the two hydrogen atoms at the experimental bond length, in Bohr (1.4011 Bohr is 0.7414 Å, the geometry of Step 1; the energy matches Step 1's up to that rounding). build_energy_fn returns a plain function geometry_bohr -> total energy in Hartree, differentiable end-to-end with jax.grad, so jax.grad(energy_fn) gives the force on each nucleus directly. Here it is ±0.029 Hartree/Bohr along the bond, not zero: with the STO-3G basis the energy minimum is slightly shorter than the experimental bond length.

H2 energy versus bond length

The curve this engine produces when the two hydrogen atoms are moved apart and the energy is computed at each distance. Pull them away and the energy rises toward two separate atoms; push them together and it rises because the nuclei repel each other. With RHF/STO-3G the minimum sits at 0.712 Å, -1.117506 Hartree; the experimental bond length is 0.741 Å. The gap is the basis set and the mean-field approximation, not the integrals.

Step 6. Unpaired electrons: UHF and ROHF

Every molecule so far has its electrons in pairs. A radical such as OH has 9 electrons, so one is left alone; transition-metal atoms (Cr, Ti, V) can have several. RHF puts both electrons of a pair in the same orbital, which cannot describe an unpaired one. UHF lets the two spins have different orbitals; ROHF keeps the pairs together and only the unpaired electrons apart.

import numpy as np
from dense_evolution.native_hf.assembly import build_core_hamiltonian, build_overlap_matrix, build_repulsion_tensor
from dense_evolution.native_hf.basis import build_molecule_shells
from dense_evolution.native_hf.scf import run_cuhf, run_uhf

geom = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.83]])
shells = build_molecule_shells([8, 1], geom, "sto-3g")
S, H, V = build_overlap_matrix(shells), build_core_hamiltonian(shells, [8.0, 1.0], geom), build_repulsion_tensor(shells)

uhf = run_uhf(S, H, V, 9, [8.0, 1.0], geom)
rohf = run_cuhf(S, H, V, 9, [8.0, 1.0], geom)
print(uhf.total_energy, uhf.spin_squared)
print(rohf.total_energy, rohf.spin_squared)
-74.36249684587374 0.7532295358699592
-74.3613919721626 0.7499999999999991

OH is an oxygen and a hydrogen 1.83 Bohr apart. n_unpaired defaults to 9 % 2 = 1, so there are 5 alpha and 4 beta electrons. spin_squared is ⟨S²⟩: a single unpaired electron should give exactly 0.75. UHF finds a slightly lower energy but ⟨S²⟩ = 0.7532, a small spin contamination from mixing in higher spin states. run_cuhf is ROHF written as a constrained UHF (Tsuchimochi & Scuseria, arXiv:1008.1607): ⟨S²⟩ = 0.75 exactly, at an energy 1.1 mHartree higher. With no unpaired electrons both give the RHF energy. For an energy-versus-distance scan, pass the previous point's orbital_coefficients_alpha / _beta as C_alpha_init / C_beta_init, so every point stays in the same SCF solution.

Step 7. A real open-shell transition metal: the Cr-O curve

Step 6 introduced UHF and ROHF (as constrained UHF) on OH and O2. The test case that motivated that work is the Cr-O dimer: neutral, an open-shell 3d metal bound to oxygen, 32 electrons, where a single closed-shell Slater determinant is the wrong ansatz and the RHF SCF oscillates between local minima at adjacent geometries. This is Dense-Evolution issue #358.

The setup is the issue's own. Cr-O dimer, 3-21g basis, 32 electrons, R from 1.6 to 2.4 Å along the x axis, ERI tensor from build_repulsion_tensor_libcint, SCF from run_uhf / run_cuhf. Warm start: the previous point's converged orbital_coefficients_alpha / orbital_coefficients_beta seed the next point's C_alpha_init / C_beta_init, so the SCF stays in the same local minimum across the whole curve. First point uses the core-Hamiltonian guess.

RHF reference (issue #358, level_shift=0.0):

k = -22 Ha/A^2      (impossible sign for a bound diatomic)
energy jumps ~0.2 Ha between adjacent geometries

Best spin state found: n_unpaired = 6, CUHF, at R_e = 1.9112 Å, E(R_e) = -1112.709156 Ha, k = 0.8454 Ha/Ų.

R (A)         E (Ha)        dE to prev   conv  iter       <S^2>

1.600 -1112.64892582 --- True 36 12.0000000000 1.657 -1112.67345374 -2.452791e-02 True 27 12.0000000000 1.714 -1112.69015513 -1.670140e-02 True 24 12.0000000000 1.771 -1112.70074104 -1.058591e-02 True 28 12.0000000000 1.829 -1112.70660382 -5.862775e-03 True 27 12.0000000000 1.886 -1112.70886625 -2.262434e-03 True 27 12.0000000000 1.943 -1112.70842308 +4.431677e-04 True 24 12.0000000000 2.000 -1112.70597669 +2.446389e-03 True 24 12.0000000000 2.057 -1112.70206892 +3.907772e-03 True 24 12.0000000000 2.114 -1112.69711018 +4.958745e-03 True 24 12.0000000000 2.171 -1112.69140609 +5.704084e-03 True 25 12.0000000000 2.229 -1112.68518136 +6.224732e-03 True 25 12.0000000000 2.286 -1112.67860023 +6.581131e-03 True 25 12.0000000000 2.343 -1112.67178336 +6.816871e-03 True 25 12.0000000000 2.400 -1112.66482098 +6.962378e-03 True 25 12.0000000000

Max neighbour jump along the curve:

max |E(R_i) - E(R_(i+1))| = 0.024528 Ha

against the issue's RHF table, where adjacent-geometry jumps reached ~0.2 Ha. Parabola fit on the five points around the minimum:

R_e      = 1.911155 A
E(R_e)   = -1112.70915634 Ha
k = 2*a2 = 0.845422 Ha/A^2

k > 0 and of the expected order of magnitude for a metal–oxygen bond; the RHF fit gave k = -22 Ha/Ų, the wrong sign and no bound state.

All three spin states tested, at their respective minima:

method n_unpaired S(S+1) E_min (Ha) R(E_min) (Å)
UHF 2 2 -1112.515060 1.6571
UHF 4 6 -1112.694485 1.7714
UHF 6 12 -1112.680050 1.8286
CUHF 2 2 -1112.432516 1.6571
CUHF 4 6 -1112.608024 1.6000
CUHF 6 12 -1112.708866 1.8857

CUHF keeps <S^2> = S(S+1) to 3.55e-14 across the whole n_unpaired = 6 curve; UHF contaminates by up to 3.82e-03 on the same state (range [12.001745, 12.003817]). For n_unpaired = 6 the UHF minimum (-1112.680050 Ha) lies above the CUHF one (-1112.708866 Ha): UHF is the less constrained method, so its true minimum cannot be higher, and the warm-started UHF scan has settled in a higher local minimum. For n_unpaired = 2 and 4 the CUHF minima sit at the edge of the grid (1.60–1.66 Å), so they are not interior minima of the scan.

All five assertions passed: every point converged, k > 0, CUHF <S^2> = S(S+1) to 1e-8, max neighbour jump below 0.05 Ha.

What is still open. DFT+U and CASSCF are the standard methods for 3d/4f transition-metal oxides and remain unimplemented. UHF and CUHF unblock the E(R) curve — a smooth curve now exists, and the parabola fit gives a physically meaningful k > 0 — but neither captures dynamic correlation, and neither accounts for localization on the metal centre. The values of R_e and k above should be read as method-consistent, not as benchmark numbers: Hartree-Fock with a small basis is known to favour high-spin states in transition-metal oxides, and the spin state and bond length found here have not been compared with a spectroscopic reference for CrO. Closing that gap is a larger change to the codebase, as the issue itself notes. To reproduce: python scripts/simulator_infrastructure/cr_o_open_shell_curve.py in Dense-Evolution-Discovery (about 2 minutes on a laptop CPU with libcint).

Step 8. Forces on an open-shell molecule

Step 5 gave the force on each nucleus of H2. The same build_energy_fn takes method="uhf" or method="cuhf", so forces work for molecules with unpaired electrons too.

import numpy as np
import jax
from dense_evolution.native_hf.differentiable import build_energy_fn

geom = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.83]])

energy_fn = build_energy_fn(
    atomic_numbers=[8, 1], nuclear_charges=[8.0, 1.0], n_electrons=9,
    basis_name="sto-3g", reference_geometry_bohr=geom,
    method="uhf", n_unpaired=1,
)

print(float(energy_fn(geom)))
print(float(jax.grad(energy_fn)(geom)[1, 2]))
-74.36249684587374
-0.057959761235710845

The energy is the UHF energy of OH from Step 6. The second number is dE/dz of the hydrogen in Hartree/Bohr; a central finite difference of the same energy (step 1e-4 Bohr) gives -0.0579597653, a difference of 4e-9. The gradient is analytic: at the converged SCF the energy is stationary, so only the explicit dependence of the integrals counts, with the spin-resolved energy-weighted density W_s = C_s,occ diag(eps_s,occ) C_s,occ^T for each spin (Lehtola, Blockhuys & Van Alsenoy, arXiv:1912.12029). For method="cuhf" the constrained Fock matrices differ from the UHF ones only in the core-virtual block of the natural-orbital basis, which does not touch the occupied orbitals, so the same W_s applies; the tests check it against finite differences on OH and O2.


Details

Why this module exists: PennyLane's own differentiable Hartree-Fock solver (qml.qchem, method="dhf") builds the same integrals through a Python-level loop wrapped in its autograd-tracing numpy layer -- correct, but profiled directly at 482 of 483 total seconds for Si2/STO-3G, almost entirely per-scalar-op tracer overhead rather than real FLOPs. This module batches each shell-pair/quartet with jax.lax.scan/ jax.vmap and compiles with jax.jit instead.

Elements beyond PennyLane's table: basis-set parameters come from basis_set_exchange, so any element it has STO-3G data for is reachable -- an element needing d-orbitals or higher (e.g. Fe) fails with a clear NotImplementedError naming the real limitation, not a silent wrong energy for an incomplete basis.

Verified against an independent implementation: element-wise against lowdanie/hartree-fock-solver ("slaterform", Apache-2.0, studied as a reference for structuring the Obara-Saika recursion with jax.lax.scan -- no source code copied) to machine precision on individual integrals. The original Si2/STO-3G full-SCF-energy cross-check against slaterform predates the DIIS/Si2-convergence fix below and has not been re-run against the corrected energy -- the per-integral cross-check is unaffected (integrals don't depend on how the SCF loop converges), but the end-to-end Si2 number should be treated as not yet re-verified against this independent reference. Algorithm background also drawn from PennyLane's own white paper (Delgado et al., "Differentiable quantum computational chemistry with PennyLane", arXiv:2111.09967).

The libcint bridge's AO conventions: libcint_bridge.py builds libcint's atm/bas/env arrays from native_hf's own shells and calls the bundled library through ctypes, so shells keep native_hf's order. Two differences remain inside each shell, confirmed empirically on Ne/6-31G*: (1) Cartesian component order -- native_hf's own cartesian_powers uses px,pz,py and xx,xz,xy,zz,yz,yy, libcint uses px,py,pz and xx,xy,xz,yy,yz,zz; (2) normalization -- libcint's raw d components are not unit-self-overlap the way native_hf's are (xx/yy/zz vs. xy/xz/yz differ by exactly a factor of 3), corrected via a rescale computed from libcint's own overlap diagonal at call time, not a hardcoded constant. A source install has no bundled library: build libcint and set DENSE_EVOLUTION_LIBCINT to the library file.

SCF convergence (run_scf): DIIS-accelerated (Pulay 1980/1982) by default, not plain linear damping -- see the module's own docstring for the real Si2 near-degenerate-orbital case that motivated this (11 DIIS iterations vs. 53 for damping alone, same converged energy to 12 significant figures) and requires both density and energy to stop changing before declaring convergence, not density alone.

Level shifting for harder near-degeneracies (run_scf(..., level_shift=...)): DIIS alone fixes the textbook Si2 case above, but a real, harder case found via a Dense-Evolution-Discovery experiment (a 30-heavy-atom aromatic fragment from the CASMI26 molecule-ID Kaggle competition wiring) still took 1114 iterations to converge, passing through three wildly different intermediate energies (-622, -521, -839 Hartree) at 200/1000/5000 iterations first -- the same near-degenerate-orbital oscillation as Si2, just harder to escape. level_shift (Saunders & Hillier, "A level shifting' method for converging closed shell Hartree-Fock wave functions", Int. J. Quantum Chem. 7, 699-705 (1973)) pushes the previous iteration's virtual orbitals up in energy before each diagonalization, opening a numerical gap that stops the occupied/virtual split from flip-flopping. On that same fragment,level_shift=0.5converges in 60 iterations andlevel_shift=1.0in 83 -- both to the identical energy (-838.928114 Hartree, matching the unshifted 1114-iteration result to full precision) -- whilelevel_shift=0.1was too weak to help within 200 iterations. Default is0.0(off, backward compatible): the transform is an exact algebraic no-op at zero shift, verified both algebraically and numerically (H2/STO-3G gives the identical converged energy atlevel_shiftin`).

AO-to-MO integral transformation: bridge._ao_to_mo's 4-index einsum + swapaxes (converting AO-basis integrals to the molecular-orbital basis, using the converged Hartree-Fock coefficients) is exactly the kind of operation where an index-ordering mistake can produce a plausible-looking but numerically wrong Hamiltonian -- cross-checked against two independent references: a sequential one-index-at-a-time transform (a different algorithm computing the same quantity, not a copy of the code under test) to 1e-10, and a real physical invariant on H2 (a basis change alone cannot alter the total electronic energy) to 1e-10.

How build_energy_fn's gradient stays differentiable. Two pieces are deliberately frozen:

  1. Schwarz screening — which shell quartets contribute to the ERI sum at all — is decided once, from the reference geometry, via assembly.quartet_screening_indices. It is a discrete decision: Python control flow, not a smooth function of position, and cannot be part of a jax.grad trace. Every call of the returned function reuses the same frozen index list.

  2. The SCF loop's gradient is analytic, from the Pople-Krishnan- Schlegel-Binkley Hartree-Fock gradient (Int. J. Quantum Chem. Symp. 13, 225 (1979), Eq. 21-22), rather than reverse-mode differentiation through jax.lax.while_loop, which JAX does not support in reverse mode.

Where the reference geometry stops being valid. If the nuclei move far enough that a previously negligible shell-pair interaction becomes non-negligible (or vice versa), the frozen screening list is stale and build_energy_fn should be called again at the new geometry.

The W matrix and the factor of 2. scf_electronic_energy's backward pass builds the energy-weighted density matrix as W = C_occ @ diag(2 * orbital_energies_occ) @ C_occ.T. The factor of 2 is this module's own P convention, not part of Pople et al.'s original spin-orbital formula — there is no explicit 2 in P itself, it is carried instead by F = H_core + 2J - K. Omitting the factor gave a gradient that disagreed with central finite differences by exactly Tr[W dS/dx].

Verified against finite differences. The custom VJP is checked against central finite differences on H2/STO-3G in tests/unit/test_native_hf_differentiable.py.

Where this is used in practice. dense_evolution.qmmm's ASE bridge (DenseEvolutionCalculator) calls this function, exposing it to ASE's optimizers and MD drivers.

ERI cost on mixed s/p/d bases: assembly.py's electron-repulsion tensor compiles one jax.jit program per distinct shell-quartet shape it encounters. A minimal s/p basis needs only a handful; a basis mixing s, p, and d shells (e.g. 6-31G) needs dozens, because the compile cache keys on primitive count per shell too, not just angular-momentum degree. Profiling (Ne/6-31G) found compile overhead alone accounted for effectively all of a single run's wall time, warm execution next to none -- assembly.py now pads every shell's primitive count up to the molecule-wide max (a repeated exponent with coefficient 0, contributing exactly zero to the sum) so shells of the same degree share one compiled program regardless of how many primitives they actually have. See prog.txt for the full measurement, including two other approaches (a persistent compilation cache, parallel compilation across processes) that were tried and abandoned.

Production entry point: dashboard_core.hamiltonians calls this engine automatically (bridge.build_qubit_hamiltonian) whenever a requested molecule uses an element outside PennyLane's own STO-3G table -- existing catalog molecules (H2/HeH+/H3+/LiH/H2O) are unaffected and keep using PennyLane's dhf pipeline directly. See that page for the dispatch logic and the Si2 catalog entry this engine backs, and dashboard_core.vqe for ansatz circuits optimized against Hamiltonians built this way.

bridge

Bridge from our native Hartree-Fock result to a PennyLane qubit Hamiltonian.

The expensive part (Hartree-Fock: integrals + SCF, everything in this package) is entirely ours. Second quantization and the Jordan-Wigner mapping are cheap (profiled at under 2 seconds even for Si2 -- see dense_evolution/native_hf/init.py's module docstring) and PennyLane already does them well via public functions, so we call those directly instead of reimplementing them.

build_qubit_hamiltonian

build_qubit_hamiltonian(
    atomic_numbers: list[int],
    geometry_angstrom: ndarray,
    n_electrons: int,
    active_electrons: int = None,
    active_orbitals: int = None,
    basis_name: str = "sto-3g",
    cutoff: float = 1e-12,
) -> tuple[qml.Hamiltonian, int, HFResult]

Runs native Hartree-Fock, then hands the result to PennyLane for second quantization + Jordan-Wigner mapping.

Returns: tuple[qml.Hamiltonian, int, HFResult]: the mapped qubit Hamiltonian, the qubit count, and the native HFResult -- the latter useful for e.g. reporting the SCF energy alongside the post-mapping ground-state energy.

Source code in dense_evolution/native_hf/bridge.py
def build_qubit_hamiltonian(
    atomic_numbers: list[int],
    geometry_angstrom: np.ndarray,
    n_electrons: int,
    active_electrons: int = None,
    active_orbitals: int = None,
    basis_name: str = "sto-3g",
    cutoff: float = 1e-12,
) -> tuple["qml.Hamiltonian", int, HFResult]:
    """Runs native Hartree-Fock, then hands the result to PennyLane for
    second quantization + Jordan-Wigner mapping.

    Returns:
        tuple[qml.Hamiltonian, int, HFResult]: the mapped qubit
        Hamiltonian, the qubit count, and the native HFResult -- the
        latter useful for e.g. reporting the SCF energy alongside the
        post-mapping ground-state energy.
    """
    if qml is None:  # pragma: no cover -- see the try/except above
        raise ModuleNotFoundError(
            "build_qubit_hamiltonian requires pennylane. "
            "Install it with: pip install dense-evolution[pennylane]"
        )

    geometry_bohr = np.asarray(geometry_angstrom) * _BOHR_PER_ANGSTROM
    shells = build_molecule_shells(atomic_numbers, geometry_bohr, basis_name)

    S = build_overlap_matrix(shells)
    H_core = build_core_hamiltonian(shells, [float(z) for z in atomic_numbers], geometry_bohr)
    repulsion = build_repulsion_tensor(shells)

    hf_result = run_scf(S, H_core, repulsion, n_electrons, [float(z) for z in atomic_numbers], geometry_bohr)

    one, two = _ao_to_mo(H_core, repulsion, hf_result.orbital_coefficients)
    n_orbitals = one.shape[0]

    if active_electrons is None and active_orbitals is None:
        core_idx, active_idx = [], list(range(n_orbitals))
    else:
        core_idx, active_idx = qml.qchem.active_space(
            n_electrons, n_orbitals, active_electrons=active_electrons, active_orbitals=active_orbitals
        )

    core_constant, one_active, two_active = _apply_active_space(
        hf_result.nuclear_repulsion_energy, one, two, core_idx, active_idx
    )

    fermi_op = _pl_observable.fermionic_observable(
        np.array([core_constant]), one_active, two_active, cutoff
    )
    qubit_hamiltonian = qml.jordan_wigner(fermi_op)
    n_qubits = 2 * len(active_idx)

    return qubit_hamiltonian, n_qubits, hf_result

scf

Restricted Hartree-Fock self-consistent field loop (Roothaan-Hall).

Standard textbook algorithm (e.g. Szabo & Ostlund, "Modern Quantum Chemistry", ch. 3): orthogonalize the AO basis via S^(-1/2), build the Fock matrix F = H_core + 2J - K from the current density, diagonalize in the orthogonal basis, form a new density, repeat to convergence. This part is genuinely simple compared to the integral evaluation and doesn't need vectorizing -- a closed-shell molecule's SCF loop is a few dozen matrix multiplies on an N x N matrix where N is a few tens at most for STO-3G, nowhere near where PennyLane's implementation loses its time (which is entirely in building H_core/repulsion tensor, done once in assembly.py, not in this loop).

BUG FOUND (Si2, minimal 4-electron/4-orbital active space, R=2.184 A): plain (undamped) density substitution never converged for this system -- 100/100 iterations, still oscillating -- because two pairs of orbitals near the active-space boundary are numerically degenerate (HOMO-1/HOMO and LUMO/LUMO+1 each split by <1e-9 Ha), so each iteration flips which member of a near-tied pair gets occupied, and the density never settles. Confirmed this is a real oscillation, not just slow convergence: three separate machines/runs of the undamped loop each hit the iteration cap at a DIFFERENT total energy (-571.63, -570.69, -571.02 Ha), all physically meaningless artifacts of whatever step the loop happened to be on. First fixed with plain linear density damping (P_next = alphaP_new + (1-alpha)P_old) -- textbook remedy for exactly this oscillation failure mode (Szabo & Ostlund ch. 3.4.9), verified to converge to the SAME energy (-570.874032094871 Ha, agreeing to 10 significant figures) across alpha in {0.1, 0.2, 0.3, 0.5, 0.7).

UPGRADED to DIIS (Pulay, "Convergence acceleration of iterative sequences: the case of SCF iteration", Chem. Phys. Lett. 73, 393 (1980); "Improved SCF convergence acceleration", J. Comput. Chem. 3, 556 (1982)) -- the standard production-grade SCF accelerator plain linear damping is a simplified special case of. Pulay's own error vector, e = X.T @ (F@P@S - S@P@F) @ X (the Fock/density commutator in the orthonormal basis, exactly zero at true self-consistency), is kept across the last diis_dim iterations alongside the Fock matrices that produced them; each step extrapolates a new Fock matrix as the minimum-norm linear combination of that history (constrained to sum to 1) instead of diagonalizing the latest F directly. Verified on the same Si2 near-degenerate case that motivated damping in the first place: DIIS converges in 11 iterations to -570.8740320948958 Ha, versus 53 iterations for plain damped substitution alone (diis_dim=0, alpha=0.5) to reach the same energy, -570.8740320948963 Ha -- agreeing to 12 significant figures, the same true self-consistent solution reached ~4.8x faster, not a different answer.

If the DIIS linear system is ever singular (a degenerate/duplicated error-vector history), that step falls back to the plain, unextrapolated Fock matrix rather than raising -- a transient fallback, not silent wrong physics, since the next iteration's fresh error vector rebuilds a usable history.

Convergence now requires BOTH the density AND the energy to stop changing (|P_new - P| < convergence_tol AND |E_new - E_old| < energy_tol) rather than density alone -- density convergence can occasionally plateau one step before energy does (or vice versa) for a system with several nearly-degenerate iterations near the end of the run; requiring both is strictly more conservative than either alone and costs at most a couple of extra iterations on every system tested here.

Rewritten from NumPy to jax.numpy (the integral-building side in assembly.py already was JAX; this loop and its np.array() outputs were the only remaining barrier to an end-to-end JAX-traced pipeline). The iteration itself uses jax.lax.while_loop for its data-dependent convergence check, same as the original Python for/break -- note that reverse-mode autodiff (jax.grad) does not work through while_loop, by JAX's own design (the number of iterations isn't known ahead of time, which reverse-mode differentiation needs). Making this loop's OUTPUT differentiable is a separate, deliberately deferred step: the correct approach is implicit differentiation at the fixed point (the custom gradient rule only needs the converged F/P, not a replay of every iteration), not unrolling this loop and backpropagating through it.

DIIS history is now a fixed-size (diis_dim, n, n) ring buffer instead of a growing/shrinking Python list -- jax.lax.while_loop requires its carried state to have constant shape across iterations. Each step rolls the buffer and writes the newest Fock/error matrix into the last slot; a boolean mask (derived from how many iterations have actually run) excludes the not-yet-filled slots from the DIIS linear system instead of the original's list-length check. The original's try/except LinAlgError fallback becomes a jnp.isfinite check on the solved coefficients (a singular solve produces NaN/Inf under JAX rather than raising), selecting the single latest Fock matrix exactly as the original's except-branch did.

UHFResult dataclass

UHFResult(
    converged: bool,
    n_iterations: int,
    electronic_energy: float,
    nuclear_repulsion_energy: float,
    total_energy: float,
    orbital_energies_alpha: Array,
    orbital_energies_beta: Array,
    orbital_coefficients_alpha: Array,
    orbital_coefficients_beta: Array,
    density_matrix_alpha: Array,
    density_matrix_beta: Array,
    spin_squared: float,
    n_alpha: int,
    n_beta: int,
    energy_history: Array,
)

UHF counterpart of HFResult, plus the spin-contamination diagnostic and the alpha/beta decomposition.

diagnose_convergence

diagnose_convergence(
    result: HFResult, radius: int = 5, n_sigmas: float = 3.0
) -> dict

Real automatic guard on HFResult.energy_history, instead of trusting the final converged flag in isolation: Dense-Armor's Hampel filter and Tukey fences (dense_evolution.utility.robust_filters -- the sister project's own anomaly detectors, Chauvenet/Tukey/Hampel/sigma-clipping, validated with 0 false positives on real H2 dissociation-curve chemistry) applied to the real per-iteration electronic energy trace.

Validated on a real non-converging case (a 28-heavy-atom CASMI26 fragment, level_shift=0.0): the broken run flags 19-24% of its 200 iterations as anomalous; the same fragment fixed with level_shift=0.5 (converges in 55 iterations) flags only ~4% -- background noise, not a false-alarm storm. Needs the armor extra (pip install dense-evolution[armor]) -- Dense-Armor is not a hard dependency of this module.

Source code in dense_evolution/native_hf/scf.py
def diagnose_convergence(result: HFResult, radius: int = 5, n_sigmas: float = 3.0) -> dict:
    """Real automatic guard on HFResult.energy_history, instead of trusting
    the final `converged` flag in isolation: Dense-Armor's Hampel filter
    and Tukey fences (dense_evolution.utility.robust_filters -- the sister
    project's own anomaly detectors, Chauvenet/Tukey/Hampel/sigma-clipping,
    validated with 0 false positives on real H2 dissociation-curve
    chemistry) applied to the real per-iteration electronic energy trace.

    Validated on a real non-converging case (a 28-heavy-atom CASMI26
    fragment, level_shift=0.0): the broken run flags 19-24% of its 200
    iterations as anomalous; the same fragment fixed with level_shift=0.5
    (converges in 55 iterations) flags only ~4% -- background noise, not a
    false-alarm storm. Needs the `armor` extra (pip install
    dense-evolution[armor]) -- Dense-Armor is not a hard dependency of this
    module."""
    hampel_filter, tukey_fences = _import_dense_armor_robust_filters()
    trace = np.asarray(result.energy_history[:result.n_iterations])
    n = max(1, result.n_iterations)

    _cleaned_h, anomalies_h = hampel_filter(trace, radius=radius, n_sigmas=n_sigmas)
    _cleaned_t, anomalies_t = tukey_fences(trace, radius=len(trace))

    return {
        "n_iterations": result.n_iterations,
        "n_anomalies_hampel": len(anomalies_h),
        "n_anomalies_tukey": len(anomalies_t),
        "anomaly_fraction_hampel": len(anomalies_h) / n,
        "anomaly_fraction_tukey": len(anomalies_t) / n,
    }

scf_electronic_energy

scf_electronic_energy(
    S: Array,
    H_core: Array,
    repulsion: Array,
    n_electrons: int,
) -> jax.Array

The RHF electronic energy (run_scf's own electronic_energy, not counting nuclear repulsion) as a function of S/H_core/repulsion that IS differentiable via jax.grad -- unlike run_scf itself, whose jax.lax.while_loop can't be traced in reverse mode.

The gradient is NOT backprop through the SCF iteration (which wouldn't even be possible) -- it's the analytic Hartree-Fock gradient of Pople, Krishnan, Schlegel & Binkley, Int. J. Quantum Chem. Symp. 13, 225 (1979), eq. (21)-(22): at self-consistency, dE/dtheta equals the derivative of Tr[P(H_core+F(P))] - Tr[W S] with P and the energy-weighted density matrix W held fixed at their converged values (an envelope-theorem/Lagrangian result -- P and W are the stationary point and multipliers of the constrained HF variational problem, so their own dependence on theta drops out of the total derivative). W is built from ONLY the occupied orbitals: W = C_occ @ diag(2 * orbital_energies_occ) @ C_occ.T -- the factor of 2 is this module's own P convention (no explicit 2 in P itself, carried instead by F = H_core + 2J - K), not part of Pople et al.'s original spin-orbital formula. This automatically includes the "Pulay force" terms from the atom-centered basis moving with the nuclei (via H_core/repulsion/S's own theta-dependence), without hand-deriving them -- jax.grad on the frozen-P expression below does that part for free.

Verified against central finite differences on H2/STO-3G (see tests/unit/test_native_hf_differentiable.py).

Source code in dense_evolution/native_hf/scf.py
@functools.partial(jax.custom_vjp, nondiff_argnums=(3,))
def scf_electronic_energy(S: jax.Array, H_core: jax.Array, repulsion: jax.Array, n_electrons: int) -> jax.Array:
    """The RHF electronic energy (run_scf's own `electronic_energy`, not
    counting nuclear repulsion) as a function of S/H_core/repulsion that
    IS differentiable via jax.grad -- unlike run_scf itself, whose
    jax.lax.while_loop can't be traced in reverse mode.

    The gradient is NOT backprop through the SCF iteration (which
    wouldn't even be possible) -- it's the analytic Hartree-Fock gradient
    of Pople, Krishnan, Schlegel & Binkley, Int. J. Quantum Chem. Symp.
    13, 225 (1979), eq. (21)-(22): at self-consistency, dE/dtheta equals
    the derivative of `Tr[P(H_core+F(P))] - Tr[W S]` with P and the
    energy-weighted density matrix W held fixed at their converged
    values (an envelope-theorem/Lagrangian result -- P and W are the
    stationary point and multipliers of the constrained HF variational
    problem, so their own dependence on theta drops out of the total
    derivative). W is built from ONLY the occupied orbitals:
    `W = C_occ @ diag(2 * orbital_energies_occ) @ C_occ.T` -- the factor
    of 2 is this module's own P convention (no explicit 2 in P itself,
    carried instead by F = H_core + 2J - K), not part of Pople et al.'s
    original spin-orbital formula. This automatically
    includes the "Pulay force" terms from the atom-centered basis moving
    with the nuclei (via H_core/repulsion/S's own theta-dependence),
    without hand-deriving them -- jax.grad on the frozen-P expression
    below does that part for free.

    Verified against central finite differences on H2/STO-3G (see
    tests/unit/test_native_hf_differentiable.py)."""
    result = run_scf(S, H_core, repulsion, n_electrons, [], jnp.zeros((0, 3)))
    return result.electronic_energy

run_uhf

run_uhf(
    S,
    H_core,
    repulsion,
    n_electrons,
    nuclear_charges,
    nuclear_positions,
    n_unpaired=None,
    C_alpha_init=None,
    C_beta_init=None,
    max_iterations=200,
    convergence_tol=1e-10,
    energy_tol=1e-10,
    damping=0.5,
    diis_dim=_DIIS_DIM,
    level_shift=0.0,
) -> UHFResult

Unrestricted Hartree-Fock SCF.

Parameters:

Name Type Description Default
n_electrons total number of electrons (integer).
required
n_unpaired number of unpaired electrons. None -> n_electrons % 2.

n_alpha = (n_electrons + n_unpaired) // 2 n_beta = (n_electrons - n_unpaired) // 2 Must satisfy n_electrons >= n_unpaired and (n_electrons - n_unpaired) even.

None
C_alpha_init optional starting orbital coefficients

from a previous geometry. Enables warm-started E(R) scans: the SCF stays in the same local minimum across a full curve instead of hopping between minima at individual points (see module docstring, "Warm-start"). If None, the core-Hamiltonian guess is used.

None
C_beta_init optional starting orbital coefficients

from a previous geometry. Enables warm-started E(R) scans: the SCF stays in the same local minimum across a full curve instead of hopping between minima at individual points (see module docstring, "Warm-start"). If None, the core-Hamiltonian guess is used.

None

Returns:

Type Description
UHFResult with electronic_energy, total_energy, both orbital sets,
both density matrices, and <S^2>.
Source code in dense_evolution/native_hf/scf.py
def run_uhf(
    S, H_core, repulsion, n_electrons, nuclear_charges, nuclear_positions,
    n_unpaired=None,
    C_alpha_init=None, C_beta_init=None,
    max_iterations=200, convergence_tol=1e-10, energy_tol=1e-10,
    damping=0.5, diis_dim=_DIIS_DIM, level_shift=0.0,
) -> UHFResult:
    """Unrestricted Hartree-Fock SCF.

    Parameters
    ----------
    n_electrons : total number of electrons (integer).
    n_unpaired : number of unpaired electrons. None -> n_electrons % 2.
        n_alpha = (n_electrons + n_unpaired) // 2
        n_beta  = (n_electrons - n_unpaired) // 2
        Must satisfy n_electrons >= n_unpaired and (n_electrons -
        n_unpaired) even.
    C_alpha_init, C_beta_init : optional starting orbital coefficients
        from a previous geometry. Enables warm-started E(R) scans: the
        SCF stays in the same local minimum across a full curve instead
        of hopping between minima at individual points (see module
        docstring, "Warm-start"). If None, the core-Hamiltonian guess is
        used.

    Returns
    -------
    UHFResult with electronic_energy, total_energy, both orbital sets,
    both density matrices, and <S^2>.
    """
    ensure_x64()

    if n_unpaired is None:
        n_unpaired = n_electrons % 2
    if n_electrons < n_unpaired or (n_electrons - n_unpaired) % 2 != 0:
        raise ValueError(
            f"Invalid (n_electrons={n_electrons}, n_unpaired={n_unpaired}): "
            f"n_electrons - n_unpaired must be a non-negative even number."
        )

    n_alpha = (n_electrons + n_unpaired) // 2
    n_beta = (n_electrons - n_unpaired) // 2
    n_basis = S.shape[0]

    X = _orthogonalizer(S)

    if C_alpha_init is not None and C_beta_init is not None:
        C_alpha_prev0 = jnp.asarray(C_alpha_init)
        C_beta_prev0 = jnp.asarray(C_beta_init)
    else:
        _e0, C_ortho0 = jnp.linalg.eigh(X.T @ H_core @ X)
        C0 = X @ C_ortho0
        C_alpha_prev0 = C0
        C_beta_prev0 = C0

    P_alpha0 = _density_from_coefficients_uhf(C_alpha_prev0, n_alpha)
    P_beta0 = _density_from_coefficients_uhf(C_beta_prev0, n_beta)

    def cond_fun(state):
        iteration, _Pa, _Pb, _Ca, _Cb, _ep, converged, *_tail = state
        return jnp.logical_and(jnp.logical_not(converged), iteration < max_iterations)

    def body_fun(state):
        (iteration, P_alpha, P_beta, C_alpha_prev, C_beta_prev,
         energy_prev, _converged,
         fh_a, fh_b, eh_a, eh_b, energy_history) = state

        F_alpha, F_beta, energy = _fock_and_energy_uhf(
            H_core, repulsion, P_alpha, P_beta
        )
        err_alpha = _diis_error(F_alpha, P_alpha, S, X)
        err_beta = _diis_error(F_beta, P_beta, S, X)

        fh_a = jnp.roll(fh_a, shift=-1, axis=0).at[-1].set(F_alpha)
        fh_b = jnp.roll(fh_b, shift=-1, axis=0).at[-1].set(F_beta)
        eh_a = jnp.roll(eh_a, shift=-1, axis=0).at[-1].set(err_alpha)
        eh_b = jnp.roll(eh_b, shift=-1, axis=0).at[-1].set(err_beta)
        history_count = jnp.minimum(iteration + 1, diis_dim)

        F_alpha_diis, F_beta_diis = _diis_extrapolate_uhf(
            fh_a, fh_b, eh_a, eh_b, history_count, diis_dim
        )
        F_alpha_step = jnp.where(history_count >= 2, F_alpha_diis, F_alpha)
        F_beta_step = jnp.where(history_count >= 2, F_beta_diis, F_beta)

        F_alpha_diag = _level_shift_fock(F_alpha_step, C_alpha_prev, S, n_alpha, level_shift)
        F_beta_diag = _level_shift_fock(F_beta_step, C_beta_prev, S, n_beta, level_shift)

        _ea, C_alpha_ortho = jnp.linalg.eigh(X.T @ F_alpha_diag @ X)
        _eb, C_beta_ortho = jnp.linalg.eigh(X.T @ F_beta_diag @ X)
        C_alpha = X @ C_alpha_ortho
        C_beta = X @ C_beta_ortho
        P_alpha_new = _density_from_coefficients_uhf(C_alpha, n_alpha)
        P_beta_new = _density_from_coefficients_uhf(C_beta, n_beta)

        density_converged = (
            jnp.linalg.norm(P_alpha_new - P_alpha)
            + jnp.linalg.norm(P_beta_new - P_beta)
        ) < convergence_tol
        energy_converged = jnp.abs(energy - energy_prev) < energy_tol
        converged = jnp.logical_and(density_converged, energy_converged)

        P_alpha_damped = jnp.where(
            history_count < 2,
            damping * P_alpha_new + (1.0 - damping) * P_alpha,
            P_alpha_new,
        )
        P_beta_damped = jnp.where(
            history_count < 2,
            damping * P_beta_new + (1.0 - damping) * P_beta,
            P_beta_new,
        )
        P_alpha_next = jnp.where(converged, P_alpha_new, P_alpha_damped)
        P_beta_next = jnp.where(converged, P_beta_new, P_beta_damped)
        energy_history = energy_history.at[iteration].set(energy)

        return (iteration + 1, P_alpha_next, P_beta_next, C_alpha, C_beta,
                energy, converged, fh_a, fh_b, eh_a, eh_b, energy_history)

    init_state = (
        jnp.array(0),
        P_alpha0, P_beta0,
        C_alpha_prev0, C_beta_prev0,
        jnp.array(jnp.inf, dtype=H_core.dtype),
        jnp.array(False),
        jnp.zeros((diis_dim, n_basis, n_basis), dtype=H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), dtype=H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), dtype=H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), dtype=H_core.dtype),
        jnp.full((max_iterations,), jnp.nan, dtype=H_core.dtype),
    )
    (iteration, P_alpha, P_beta, C_alpha, C_beta,
     _ep, converged, _fha, _fhb, _eha, _ehb, energy_history) = (
        jax.lax.while_loop(cond_fun, body_fun, init_state)
    )

    F_alpha, F_beta, electronic_energy = _fock_and_energy_uhf(
        H_core, repulsion, P_alpha, P_beta
    )
    orbital_energies_alpha = jnp.diag(C_alpha.T @ F_alpha @ C_alpha)
    orbital_energies_beta = jnp.diag(C_beta.T @ F_beta @ C_beta)
    e_nuc = nuclear_repulsion_energy(nuclear_charges, nuclear_positions)
    spin_sq = _spin_squared(P_alpha, P_beta, S, n_alpha, n_beta)

    return UHFResult(
        converged=bool(converged),
        n_iterations=int(iteration),
        electronic_energy=float(electronic_energy),
        nuclear_repulsion_energy=float(e_nuc),
        total_energy=float(electronic_energy) + float(e_nuc),
        orbital_energies_alpha=orbital_energies_alpha,
        orbital_energies_beta=orbital_energies_beta,
        orbital_coefficients_alpha=C_alpha,
        orbital_coefficients_beta=C_beta,
        density_matrix_alpha=P_alpha,
        density_matrix_beta=P_beta,
        spin_squared=float(spin_sq),
        n_alpha=n_alpha,
        n_beta=n_beta,
        energy_history=energy_history,
    )

run_cuhf

run_cuhf(
    S,
    H_core,
    repulsion,
    n_electrons,
    nuclear_charges,
    nuclear_positions,
    n_unpaired=None,
    C_alpha_init=None,
    C_beta_init=None,
    max_iterations=200,
    convergence_tol=1e-10,
    energy_tol=1e-10,
    damping=0.5,
    diis_dim=_DIIS_DIM,
    level_shift=0.0,
)

Constrained UHF (ROHF) SCF, Tsuchimochi & Scuseria (arXiv:1008.1607).

Same arguments and result fields as run_uhf. The alpha and beta orbitals differ, but the density is that of a restricted open-shell determinant: spin_squared equals S_z(S_z + 1) to machine precision, and with n_unpaired=0 the energy equals the RHF energy.

Source code in dense_evolution/native_hf/scf.py
def run_cuhf(S, H_core, repulsion, n_electrons, nuclear_charges,
             nuclear_positions, n_unpaired=None,
             C_alpha_init=None, C_beta_init=None,
             max_iterations=200, convergence_tol=1e-10, energy_tol=1e-10,
             damping=0.5, diis_dim=_DIIS_DIM, level_shift=0.0):
    """Constrained UHF (ROHF) SCF, Tsuchimochi & Scuseria (arXiv:1008.1607).

    Same arguments and result fields as `run_uhf`. The alpha and beta
    orbitals differ, but the density is that of a restricted open-shell
    determinant: `spin_squared` equals S_z(S_z + 1) to machine precision,
    and with `n_unpaired=0` the energy equals the RHF energy.
    """
    ensure_x64()

    if n_unpaired is None:
        n_unpaired = n_electrons % 2
    if n_electrons < n_unpaired or (n_electrons - n_unpaired) % 2 != 0:
        raise ValueError(f"Invalid (n_electrons={n_electrons}, n_unpaired={n_unpaired})")

    n_alpha = (n_electrons + n_unpaired) // 2
    n_beta = (n_electrons - n_unpaired) // 2
    n_basis = S.shape[0]
    X = _orthogonalizer(S)

    if C_alpha_init is not None and C_beta_init is not None:
        C0_a = jnp.asarray(C_alpha_init)
        C0_b = jnp.asarray(C_beta_init)
    else:
        _e0, C_ortho0 = jnp.linalg.eigh(X.T @ H_core @ X)
        C0_a = X @ C_ortho0
        C0_b = C0_a

    P_a0 = _density_from_coefficients_uhf(C0_a, n_alpha)
    P_b0 = _density_from_coefficients_uhf(C0_b, n_beta)

    ar = jnp.arange(n_basis)
    core = ar < n_beta
    virt = ar >= n_alpha
    cv_mask = (core[:, None] & virt[None, :]) | (virt[:, None] & core[None, :])

    def cond_fun(s):
        return jnp.logical_and(jnp.logical_not(s[6]), s[0] < max_iterations)

    def body_fun(s):
        (it, Pa, Pb, Ca_p, Cb_p, E_prev, _c,
         fh_a, fh_b, eh_a, eh_b, eh_hist) = s

        Fa_t, Fb_t, E = _cuhf_modified_focks(H_core, repulsion, Pa, Pb, cv_mask, S)

        err_a = _diis_error(Fa_t, Pa, S, X)
        err_b = _diis_error(Fb_t, Pb, S, X)

        fh_a = jnp.roll(fh_a, -1, 0).at[-1].set(Fa_t)
        fh_b = jnp.roll(fh_b, -1, 0).at[-1].set(Fb_t)
        eh_a = jnp.roll(eh_a, -1, 0).at[-1].set(err_a)
        eh_b = jnp.roll(eh_b, -1, 0).at[-1].set(err_b)
        hc = jnp.minimum(it + 1, diis_dim)

        Fa_d, Fb_d = _diis_extrapolate_uhf(fh_a, fh_b, eh_a, eh_b, hc, diis_dim)
        Fa_s = jnp.where(hc >= 2, Fa_d, Fa_t)
        Fb_s = jnp.where(hc >= 2, Fb_d, Fb_t)

        Fa_dg = _level_shift_fock(Fa_s, Ca_p, S, n_alpha, level_shift)
        Fb_dg = _level_shift_fock(Fb_s, Cb_p, S, n_beta, level_shift)

        _ea, Ca_o = jnp.linalg.eigh(X.T @ Fa_dg @ X)
        _eb, Cb_o = jnp.linalg.eigh(X.T @ Fb_dg @ X)
        Ca = X @ Ca_o
        Cb = X @ Cb_o
        Pa_n = _density_from_coefficients_uhf(Ca, n_alpha)
        Pb_n = _density_from_coefficients_uhf(Cb, n_beta)

        d_conv = (jnp.linalg.norm(Pa_n - Pa) + jnp.linalg.norm(Pb_n - Pb)) < convergence_tol
        e_conv = jnp.abs(E - E_prev) < energy_tol
        conv = jnp.logical_and(d_conv, e_conv)

        Pa_d = jnp.where(hc < 2, damping * Pa_n + (1 - damping) * Pa, Pa_n)
        Pb_d = jnp.where(hc < 2, damping * Pb_n + (1 - damping) * Pb, Pb_n)
        Pa_x = jnp.where(conv, Pa_n, Pa_d)
        Pb_x = jnp.where(conv, Pb_n, Pb_d)
        eh_hist = eh_hist.at[it].set(E)

        return (it + 1, Pa_x, Pb_x, Ca, Cb, E, conv,
                fh_a, fh_b, eh_a, eh_b, eh_hist)

    init = (
        jnp.array(0), P_a0, P_b0, C0_a, C0_b,
        jnp.array(jnp.inf, H_core.dtype), jnp.array(False),
        jnp.zeros((diis_dim, n_basis, n_basis), H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), H_core.dtype),
        jnp.zeros((diis_dim, n_basis, n_basis), H_core.dtype),
        jnp.full((max_iterations,), jnp.nan, H_core.dtype),
    )
    out = jax.lax.while_loop(cond_fun, body_fun, init)
    it, Pa, Pb, Ca, Cb, _Ep, conv, _fa, _fb, _ea, _eb, eh = out

    P_tot = Pa + Pb
    J = jnp.einsum("pqrs,rs->pq", repulsion, P_tot)
    Fa = H_core + J - jnp.einsum("prqs,rs->pq", repulsion, Pa)
    Fb = H_core + J - jnp.einsum("prqs,rs->pq", repulsion, Pb)
    E_el = 0.5 * (jnp.sum(Pa * (H_core + Fa)) + jnp.sum(Pb * (H_core + Fb)))
    e_nuc = nuclear_repulsion_energy(nuclear_charges, nuclear_positions)
    s2 = _spin_squared(Pa, Pb, S, n_alpha, n_beta)

    return CUHFResult(
        converged=bool(conv),
        n_iterations=int(it),
        electronic_energy=float(E_el),
        nuclear_repulsion_energy=float(e_nuc),
        total_energy=float(E_el) + float(e_nuc),
        orbital_energies_alpha=jnp.diag(Ca.T @ Fa @ Ca),
        orbital_energies_beta=jnp.diag(Cb.T @ Fb @ Cb),
        orbital_coefficients_alpha=Ca,
        orbital_coefficients_beta=Cb,
        density_matrix_alpha=Pa,
        density_matrix_beta=Pb,
        spin_squared=float(s2),
        n_alpha=n_alpha,
        n_beta=n_beta,
        energy_history=eh,
    )

uhf_electronic_energy

uhf_electronic_energy(
    S, H_core, repulsion, n_alpha, n_beta
)

UHF electronic energy, differentiable w.r.t. (S, H_core, repulsion). n_alpha, n_beta are static; n_unpaired = n_alpha - n_beta.

Source code in dense_evolution/native_hf/scf.py
@functools.partial(jax.custom_vjp, nondiff_argnums=(3, 4))
def uhf_electronic_energy(S, H_core, repulsion, n_alpha, n_beta):
    """UHF electronic energy, differentiable w.r.t. (S, H_core, repulsion).
    n_alpha, n_beta are static; n_unpaired = n_alpha - n_beta."""
    E, *_ = _uhf_scf_jnp(S, H_core, repulsion, n_alpha, n_beta, _N_SCF_ITER)
    return E

cuhf_electronic_energy

cuhf_electronic_energy(
    S, H_core, repulsion, n_alpha, n_beta
)

CUHF (ROHF as constrained UHF, Tsuchimochi & Scuseria) electronic energy, differentiable w.r.t. (S, H_core, repulsion). Same backward W as UHF (see the module note above: F~_s and F_s agree on occ-occ).

Source code in dense_evolution/native_hf/scf.py
@functools.partial(jax.custom_vjp, nondiff_argnums=(3, 4))
def cuhf_electronic_energy(S, H_core, repulsion, n_alpha, n_beta):
    """CUHF (ROHF as constrained UHF, Tsuchimochi & Scuseria) electronic
    energy, differentiable w.r.t. (S, H_core, repulsion). Same backward W
    as UHF (see the module note above: F~_s and F_s agree on occ-occ)."""
    E, *_ = _cuhf_scf_jnp(S, H_core, repulsion, n_alpha, n_beta, _N_SCF_ITER)
    return E

basis

Loading contracted basis-set shells for a molecule.

Basis-set parameters (exponents, contraction coefficients) are fetched from the Basis Set Exchange (the basis_set_exchange PyPI package, data-only, BSD-licensed -- https://www.basissetexchange.org), so this module works for any element the basis has data for, not just the handful PennyLane's own bundled STO-3G table covers (which stops at Ne and is why Silicon needed a hand-written patch earlier in this project).

The coefficients BSE reports are for unnormalized primitives, so each primitive's contraction coefficient must be rescaled by its own L2 norm before it can be summed against another shell's primitives. We get that norm for free by reusing our own overlap_3d on the primitive against itself: N = 1/sqrt().

ContractedShell dataclass

ContractedShell(
    atom_index: int,
    center: Array,
    degree: int,
    exponents: Array,
    coefficients: Array,
)

One angular-momentum shell (s, p, ...) of a contracted GTO.

build_molecule_shells

build_molecule_shells(
    atomic_numbers: list[int],
    geometry_bohr: ndarray,
    basis_name: str,
) -> list[ContractedShell]

geometry_bohr: shape (n_atoms, 3), atomic units.

Source code in dense_evolution/native_hf/basis.py
def build_molecule_shells(atomic_numbers: list[int], geometry_bohr: np.ndarray, basis_name: str) -> list[ContractedShell]:
    """geometry_bohr: shape (n_atoms, 3), atomic units."""
    ensure_x64()
    shells = []
    for i, (z, r) in enumerate(zip(atomic_numbers, geometry_bohr)):
        shells.extend(load_element_shells(basis_name, z, jnp.asarray(r), i))
    return shells

libcint_bridge

libcint bridge for native_hf's one- and two-electron integrals.

native_hf's own build_repulsion_tensor (assembly.py) computes the ERI tensor via JAX-jitted Obara-Saika recursions -- correct, differentiable, but pays a JIT-compilation tax on mixed-angular-momentum bases (~532s on Ne/6-31G even after the primitive-count-padding fix), and its one-electron assembly ran out of memory during XLA compilation for a 4-heavy-atom molecule at 6-31G on a Kaggle CPU kernel. libcint (Sun, J. Comput. Chem. 36, 1664 (2015), BSD-2) computes the identical integrals with C kernels compiled once, ahead of time.

libcint is linked statically, together with csrc/cint_driver.c, into one shared library shipped inside the platform wheels of dense-evolution (dense_evolution/native_hf/_libcint/, built by .github/scripts/build-libcint.sh in .github/workflows/libcint.yml) and called through ctypes, with the atm/bas/env arrays built here from native_hf's own ContractedShell list. The driver loops over shell blocks in C (8-fold ERI symmetry, int2e_optimizer) and writes every integral already rescaled and in native_hf's AO order. A source install has no bundled library: run build-libcint.sh and point DENSE_EVOLUTION_LIBCINT at the resulting file.

Shells are passed to libcint in native_hf's own order, so AO blocks line up shell by shell. Two convention differences remain inside each shell, confirmed empirically on Ne/6-31G*: - Cartesian component order: libcint orders px,py,pz and xx,xy,xz,yy,yz,zz; native_hf's cartesian_powers orders px,pz,py and xx,xz,xy,zz,yz,yy. - Normalization: libcint's raw Cartesian d components are not unit self-overlap (xx/yy/zz vs. xy/xz/yz differ by exactly a factor of 3). native_hf's primitive-normalized coefficients differ from libcint's radial convention by a per-shell constant too. Both are removed by a per-AO rescale computed from libcint's own overlap diagonal at call time, exact for whatever exponent/element/degree is in play. Degrees above 2 raise NotImplementedError (native_hf itself caps at 2).

load_libcint

load_libcint() -> ctypes.CDLL

The bundled libcint + driver shared library (or DENSE_EVOLUTION_LIBCINT's), loaded once; ImportError if neither exists.

Source code in dense_evolution/native_hf/libcint_bridge.py
def load_libcint() -> ctypes.CDLL:
    """The bundled libcint + driver shared library (or DENSE_EVOLUTION_LIBCINT's),
    loaded once; ImportError if neither exists."""
    global _lib
    if _lib is not None:
        return _lib
    path = os.environ.get("DENSE_EVOLUTION_LIBCINT") or str(
        Path(__file__).with_name("_libcint") / _LIB_NAMES.get(sys.platform, "libdecint.so")
    )
    if not os.path.isfile(path):
        raise ImportError(
            f"native_hf.libcint_bridge needs the bundled libcint library, expected at {path}. "
            "It ships inside the dense-evolution wheels for Windows, macOS and Linux "
            "(pip install dense-evolution); for a source install, run "
            ".github/scripts/build-libcint.sh and set DENSE_EVOLUTION_LIBCINT to the library file."
        )
    lib = ctypes.CDLL(path)
    tail = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
    lib.de_int1e.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_int] + [ctypes.c_void_p] * 3 + tail
    lib.de_int1e.restype = None
    lib.de_int2e.argtypes = [ctypes.c_void_p, ctypes.c_int] + [ctypes.c_void_p] * 3 + tail
    lib.de_int2e.restype = None
    _lib = lib
    return lib

build_overlap_and_core_hamiltonian_libcint

build_overlap_and_core_hamiltonian_libcint(
    atomic_numbers: list,
    geometry_bohr: ndarray,
    basis_name: str,
) -> tuple[np.ndarray, np.ndarray]

Same contract as assembly.build_overlap_matrix + build_core_hamiltonian combined, computed via libcint. Returns (S, H_core) in native_hf's own AO ordering/normalization, ready for scf.run_scf alongside build_repulsion_tensor_libcint's output.

Source code in dense_evolution/native_hf/libcint_bridge.py
def build_overlap_and_core_hamiltonian_libcint(
    atomic_numbers: list, geometry_bohr: np.ndarray, basis_name: str
) -> tuple[np.ndarray, np.ndarray]:
    """Same contract as assembly.build_overlap_matrix + build_core_hamiltonian
    combined, computed via libcint. Returns (S, H_core) in native_hf's own AO
    ordering/normalization, ready for scf.run_scf alongside
    build_repulsion_tensor_libcint's output."""
    c = _Cint(atomic_numbers, geometry_bohr, basis_name)
    r = c.rescale()
    return c.one("ovlp", r, c.pos), c.one("kin", r, c.pos) + c.one("nuc", r, c.pos)

build_repulsion_tensor_libcint

build_repulsion_tensor_libcint(
    atomic_numbers: list,
    geometry_bohr: ndarray,
    basis_name: str,
) -> np.ndarray

Same contract as assembly.build_repulsion_tensor, computed via libcint with 8-fold permutational symmetry; takes (atomic_numbers, geometry_bohr, basis_name) rather than a shells list.

geometry_bohr: shape (n_atoms, 3), atomic units, same convention as build_molecule_shells.

Source code in dense_evolution/native_hf/libcint_bridge.py
def build_repulsion_tensor_libcint(atomic_numbers: list, geometry_bohr: np.ndarray, basis_name: str) -> np.ndarray:
    """Same contract as assembly.build_repulsion_tensor, computed via libcint
    with 8-fold permutational symmetry; takes (atomic_numbers, geometry_bohr,
    basis_name) rather than a shells list.

    geometry_bohr: shape (n_atoms, 3), atomic units, same convention as
    build_molecule_shells."""
    c = _Cint(atomic_numbers, geometry_bohr, basis_name)
    return c.two(c.rescale())

differentiable

A differentiable-w.r.t.-nuclear-positions RHF total energy, composed from basis.py + assembly.py + scf.py.

Two separate, deliberate pieces make this possible, neither of which is a property of any one of those modules alone:

  1. Schwarz screening (assembly.py's quartet_screening_indices) is a discrete, structural decision -- which shell quartets exist in the ERI sum at all -- and can't be part of a jax.grad trace (Python control flow can't run on a traced value). It's decided ONCE here, from the reference geometry passed to build_energy_fn, and reused as a fixed structure for every geometry the returned function is later called at.

  2. scf.py's iterative convergence search (jax.lax.while_loop) can't be differentiated in reverse mode either -- scf_electronic_energy sidesteps that with the analytic Hartree-Fock gradient (Pople, Krishnan, Schlegel & Binkley, 1979) instead of backpropagating through the loop.

The returned function is only valid near the reference geometry: if the nuclei move far enough that a previously-negligible shell-pair interaction becomes non-negligible (or vice versa), the frozen screening structure is stale and build_energy_fn should be called again at the new geometry.

build_energy_fn

build_energy_fn(
    atomic_numbers: list[int],
    nuclear_charges: list[float],
    n_electrons: int,
    basis_name: str,
    reference_geometry_bohr: ndarray,
    screening_tol: float = 1e-12,
    method: str = "rhf",
    n_unpaired: int | None = None,
)

method is "rhf" (default), "uhf" or "cuhf"; for the open-shell methods n_unpaired defaults to n_electrons % 2. The UHF/CUHF gradients use the spin-resolved energy-weighted density (see scf.py).

Source code in dense_evolution/native_hf/differentiable.py
def build_energy_fn(
    atomic_numbers: list[int], nuclear_charges: list[float], n_electrons: int,
    basis_name: str, reference_geometry_bohr: np.ndarray, screening_tol: float = 1e-12,
    method: str = "rhf", n_unpaired: int | None = None,
):
    """`method` is "rhf" (default), "uhf" or "cuhf"; for the open-shell methods
    `n_unpaired` defaults to `n_electrons % 2`. The UHF/CUHF gradients use the
    spin-resolved energy-weighted density (see scf.py)."""
    if method == "rhf":
        electronic = lambda S, H, V: scf_electronic_energy(S, H, V, n_electrons)
    elif method in ("uhf", "cuhf"):
        if n_unpaired is None:
            n_unpaired = n_electrons % 2
        if n_electrons < n_unpaired or (n_electrons - n_unpaired) % 2 != 0:
            raise ValueError(f"Invalid (n_electrons={n_electrons}, n_unpaired={n_unpaired})")
        n_alpha, n_beta = (n_electrons + n_unpaired) // 2, (n_electrons - n_unpaired) // 2
        fn = uhf_electronic_energy if method == "uhf" else cuhf_electronic_energy
        electronic = lambda S, H, V: fn(S, H, V, n_alpha, n_beta)
    else:
        raise ValueError(f"method must be 'rhf', 'uhf' or 'cuhf', got {method!r}")

    reference_shells = build_molecule_shells(atomic_numbers, np.asarray(reference_geometry_bohr), basis_name)
    quartet_indices = quartet_screening_indices(reference_shells, screening_tol)

    def energy_fn(geometry_bohr):
        shells = build_molecule_shells(atomic_numbers, geometry_bohr, basis_name)
        S = build_overlap_matrix(shells)
        H_core = build_core_hamiltonian(shells, nuclear_charges, geometry_bohr)
        repulsion = build_repulsion_tensor(shells, quartet_indices=quartet_indices)
        electronic_energy = electronic(S, H_core, repulsion)
        e_nuc = nuclear_repulsion_energy(nuclear_charges, geometry_bohr)
        return electronic_energy + e_nuc

    return energy_fn

gaussians

Primitive Gaussian shells and the Gaussian product theorem.

A primitive Gaussian of angular momentum degree L centered at R with exponent a is, in 3D:

G(r) = (x-Rx)^lx (y-Ry)^ly (z-Rz)^lz * exp(-a |r-R|^2)

We only ever need the maximum degree L for a shell (s: L=0, p: L=1, ...) because the recursions below build every (lx,ly,lz) with lx+ly+lz <= L in one shot, so a shell is fully described by (L, a, R).

GaussianShell1D dataclass

GaussianShell1D(
    degree: int, exponent: Array, center: Array
)

A single Cartesian component (x, y, or z) of a Gaussian shell.

GaussianShell3D dataclass

GaussianShell3D(
    degree: int, exponent: Array, center: Array
)

A 3D Gaussian shell: one exponent/center, all (lx,ly,lz) with lx+ly+lz <= degree implicitly represented.

product_center

product_center(
    g1: GaussianShell3D, g2: GaussianShell3D
) -> jax.Array

The center P of the Gaussian obtained by multiplying two Gaussians (Gaussian product theorem): P = (aA + bB) / (a+b).

Source code in dense_evolution/native_hf/gaussians.py
def product_center(g1: GaussianShell3D, g2: GaussianShell3D) -> jax.Array:
    """The center P of the Gaussian obtained by multiplying two Gaussians
    (Gaussian product theorem): P = (a*A + b*B) / (a+b)."""
    a, b = jnp.asarray(g1.exponent), jnp.asarray(g2.exponent)
    A, B = jnp.asarray(g1.center), jnp.asarray(g2.center)
    return (a * A + b * B) / (a + b)

product_prefactor

product_prefactor(
    g1: GaussianShell3D, g2: GaussianShell3D
) -> jax.Array

The scalar prefactor K = exp(-mu |A-B|^2), mu = a*b/(a+b), that the product of two Gaussians picks up (everything else about the product is folded into the recursions below).

Source code in dense_evolution/native_hf/gaussians.py
def product_prefactor(g1: GaussianShell3D, g2: GaussianShell3D) -> jax.Array:
    """The scalar prefactor K = exp(-mu |A-B|^2), mu = a*b/(a+b), that the
    product of two Gaussians picks up (everything else about the product
    is folded into the recursions below)."""
    a, b = jnp.asarray(g1.exponent), jnp.asarray(g2.exponent)
    diff = jnp.asarray(g1.center) - jnp.asarray(g2.center)
    mu = (a * b) / (a + b)
    return jnp.exp(-mu * jnp.dot(diff, diff))

boys

The Boys function F_n(x), needed to evaluate any integral involving 1/r12 (nuclear attraction, electron repulsion) between Gaussians.

F_n(x) = integral_0^1  t^(2n) exp(-x t^2) dt

which can be written in closed form via the regularized lower incomplete gamma function P:

F_n(x) = Gamma(n + 1/2) * P(n + 1/2, x) / (2 * x^(n + 1/2))

For x -> 0 this formula divides 0/0, so we fall back to the first-order Taylor expansion F_n(x) ~= 1/(2n+1) - x/(2n+3), which is accurate to better than machine epsilon once x is small enough that no other term in the calculation could still be sensitive to it.

boys

boys(n: Array, x: Array) -> jax.Array

Evaluate F_n(x) elementwise. n and x broadcast against each other.

Source code in dense_evolution/native_hf/boys.py
def boys(n: jax.Array, x: jax.Array) -> jax.Array:
    """Evaluate F_n(x) elementwise. n and x broadcast against each other."""
    x = jnp.asarray(x)
    n = jnp.asarray(n)

    x_safe = jnp.where(x < _TAYLOR_CUTOFF, 1.0, x)
    log_gamma = gammaln(n + 0.5)
    closed_form = jnp.exp(log_gamma) * gammainc(n + 0.5, x_safe) / (2.0 * x_safe ** (n + 0.5))

    taylor = 1.0 / (2.0 * n + 1.0) - x / (2.0 * n + 3.0)

    return jnp.where(x < _TAYLOR_CUTOFF, taylor, closed_form)

overlap

Overlap integrals between Gaussian shells via the Obara-Saika recursion.

For two 1D Gaussians centered at A, B with exponents a, b, let p = a+b and P = (aA+bB)/p be the center of their product (Gaussian product theorem). The overlap S[i,j] = <(x-A)^i exp(-a(x-A)^2) | (x-B)^j exp(-b(x-B)^2)> obeys two recursions:

Vertical (build up the first index from the base case S[0,0]): S[i,0] = (P-A) S[i-1,0] + (i-1)/(2p) S[i-2,0]

Horizontal (build up the second index by shifting angular momentum from center A to center B, exact for any two centers -- this is why it needs no exponent-dependent term): S[i,j] = (A-B) S[i,j-1] + S[i+1,j-1]

Both are linear recursions in one index with a 2-term memory, so each maps directly onto jax.lax.scan: the whole angular-momentum ladder for a shell pair compiles to one XLA loop instead of a Python for-loop.

overlap_1d

overlap_1d(
    g1: GaussianShell1D, g2: GaussianShell1D
) -> jax.Array

S[i,j] for 0<=i<=g1.degree, 0<=j<=g2.degree. Shape (g1.degree+1, g2.degree+1).

Source code in dense_evolution/native_hf/overlap.py
def overlap_1d(g1: GaussianShell1D, g2: GaussianShell1D) -> jax.Array:
    """S[i,j] for 0<=i<=g1.degree, 0<=j<=g2.degree. Shape (g1.degree+1, g2.degree+1)."""
    s00 = _base_overlap_1d(g1, g2)
    first_col = _build_first_index(s00, g1, g2, g1.degree + g2.degree)
    full = _build_second_index(first_col, g1, g2)
    return full[: g1.degree + 1, :]

overlap_3d

overlap_3d(
    g1: GaussianShell3D, g2: GaussianShell3D
) -> jax.Array

S[ix,iy,iz,jx,jy,jz] for the full Cartesian shell pair.

Shape: (L1+1,L1+1,L1+1, L2+1,L2+1,L2+1) where L1, L2 are the shell degrees (unphysical (i,j,k) combinations with i+j+k > degree are simply never read by the caller).

Source code in dense_evolution/native_hf/overlap.py
@jax.jit
def overlap_3d(g1: GaussianShell3D, g2: GaussianShell3D) -> jax.Array:
    """S[ix,iy,iz,jx,jy,jz] for the full Cartesian shell pair.

    Shape: (L1+1,L1+1,L1+1, L2+1,L2+1,L2+1) where L1, L2 are the shell
    degrees (unphysical (i,j,k) combinations with i+j+k > degree are
    simply never read by the caller).
    """
    axes = [overlap_1d(g1.component(d), g2.component(d)) for d in range(3)]
    return jnp.einsum("ad,be,cf->abcdef", *axes)

cartesian

Enumerating the physical (lx,ly,lz) Cartesian components of a shell.

Our integral tensors are shaped (degree+1, degree+1, degree+1, ...) for convenience, but only the (lx,ly,lz) triples with lx+ly+lz == degree are physical basis functions -- e.g. for a p shell (degree=1) that's (1,0,0), (0,1,0), (0,0,1), not e.g. (1,1,0) which the tensor shape happens to also have room for.

cartesian_powers

cartesian_powers(degree: int) -> np.ndarray

Returns an (M, 3) array of (lx,ly,lz) triples with lx+ly+lz == degree, in a fixed canonical order determined by the generator below -- not the common lexicographic convention some other codes use. For p (degree=1) that's px, pz, py, not px, py, pz (confirmed by calling this function directly, not assumed from the shape of the loop); for d (degree=2), xx, xz, xy, zz, yz, yy, not the lexicographic xx, xy, xz, yy, yz, zz. Internally consistent (cartesian_normalization_ratios and every caller in assembly.py use this exact order), but this matters for anything that needs to match a DIFFERENT code's AO ordering (e.g. libcint's) -- see native_hf/libcint_bridge.py's per-degree permutation.

Source code in dense_evolution/native_hf/cartesian.py
def cartesian_powers(degree: int) -> np.ndarray:
    """Returns an (M, 3) array of (lx,ly,lz) triples with lx+ly+lz == degree,
    in a fixed canonical order determined by the generator below -- not the
    common lexicographic convention some other codes use. For p (degree=1)
    that's px, pz, py, not px, py, pz (confirmed by calling this function
    directly, not assumed from the shape of the loop); for d (degree=2),
    xx, xz, xy, zz, yz, yy, not the lexicographic xx, xy, xz, yy, yz, zz.
    Internally consistent (cartesian_normalization_ratios and every caller
    in assembly.py use this exact order), but this matters for anything
    that needs to match a DIFFERENT code's AO ordering (e.g. libcint's) --
    see native_hf/libcint_bridge.py's per-degree permutation."""
    return np.array(
        [(lx, degree - lx - lz, lz) for lx in range(degree, -1, -1) for lz in range(degree - lx, -1, -1)],
        dtype=np.int32,
    )

cartesian_normalization_ratios

cartesian_normalization_ratios(degree: int) -> np.ndarray

Relative normalization of each Cartesian component of a shell of the given degree, relative to the (degree,0,0) component -- 1.0 for every component when degree<=1 (px, py, pz are equivalent by symmetry), but genuinely different starting at degree=2 (e.g. dxy needs a larger normalization constant than dxx, since = 3* for the same exponent).

Standard result for an unnormalized Cartesian Gaussian primitive (x-Rx)^lx (y-Ry)^ly (z-Rz)^lz exp(-a|r-R|^2): its normalization constant is proportional to 1/sqrt((2lx-1)!!(2ly-1)!!(2lz-1)!!), so this ratio -- independent of the exponent, verified numerically against overlap_3d's own self-overlap at several exponents -- is sqrt((2*degree-1)!! / ((2lx-1)!!(2ly-1)!!(2lz-1)!!)).

Order matches cartesian_powers(degree) exactly, so callers can zip or elementwise-multiply the two directly.

Source code in dense_evolution/native_hf/cartesian.py
def cartesian_normalization_ratios(degree: int) -> np.ndarray:
    """Relative normalization of each Cartesian component of a shell of
    the given degree, relative to the (degree,0,0) component -- 1.0 for
    every component when degree<=1 (px, py, pz are equivalent by
    symmetry), but genuinely different starting at degree=2 (e.g. dxy
    needs a larger normalization constant than dxx, since <dxx|dxx> =
    3*<dxy|dxy> for the same exponent).

    Standard result for an unnormalized Cartesian Gaussian primitive
    (x-Rx)^lx (y-Ry)^ly (z-Rz)^lz exp(-a|r-R|^2): its normalization
    constant is proportional to 1/sqrt((2lx-1)!!(2ly-1)!!(2lz-1)!!), so
    this ratio -- independent of the exponent, verified numerically
    against overlap_3d's own self-overlap at several exponents -- is
    sqrt((2*degree-1)!! / ((2lx-1)!!(2ly-1)!!(2lz-1)!!)).

    Order matches cartesian_powers(degree) exactly, so callers can zip
    or elementwise-multiply the two directly."""
    powers = cartesian_powers(degree)
    reference = _double_factorial_odd(degree)
    return np.array(
        [
            np.sqrt(reference / (_double_factorial_odd(lx) * _double_factorial_odd(ly) * _double_factorial_odd(lz)))
            for lx, ly, lz in powers
        ]
    )

kinetic

Kinetic energy integrals, obtained from overlap integrals for free.

The second derivative of a Gaussian (x-B)^j exp(-b(x-B)^2) with respect to its own center reduces to a combination of Gaussians of degree j-2, j and j+2, which gives kinetic integrals as a fixed linear combination of overlap integrals evaluated at a boosted degree:

T[i,j] = j(j-1) S[i,j-2] - 2b(2j+1) S[i,j] + 4b^2 S[i,j+2]

T here is ; the physical kinetic energy operator is -1/2 (d^2/dx^2 + d^2/dy^2 + d^2/dz^2), so callers must negate and halve the sum of the three Cartesian terms.

kinetic_3d

kinetic_3d(
    g1: GaussianShell3D, g2: GaussianShell3D
) -> jax.Array

, shape matching overlap_3d.

Multiply by -0.5 to get the physical kinetic-energy matrix elements.

Source code in dense_evolution/native_hf/kinetic.py
@jax.jit
def kinetic_3d(g1: GaussianShell3D, g2: GaussianShell3D) -> jax.Array:
    """<g1| d^2/dx^2 + d^2/dy^2 + d^2/dz^2 |g2>, shape matching overlap_3d.

    Multiply by -0.5 to get the physical kinetic-energy matrix elements.
    """
    b = jnp.asarray(g2.exponent)
    g2_boosted = GaussianShell3D(degree=g2.degree + 2, exponent=g2.exponent, center=g2.center)

    S = [overlap_1d(g1.component(d), g2_boosted.component(d)) for d in range(3)]
    T = [_kinetic_1d_from_overlap(S[d], b) for d in range(3)]
    S_trim = [s[:, :-2] for s in S]

    term_x = jnp.einsum("ad,be,cf->abcdef", T[0], S_trim[1], S_trim[2])
    term_y = jnp.einsum("ad,be,cf->abcdef", S_trim[0], T[1], S_trim[2])
    term_z = jnp.einsum("ad,be,cf->abcdef", S_trim[0], S_trim[1], T[2])

    return term_x + term_y + term_z

coulomb

Nuclear attraction and electron repulsion integrals.

Both integral types reduce to the same building block: the n-th order Hermite Coulomb integral

V_n(P, C) = K * F_n(p |P-C|^2)

where F_n is the Boys function, P the center of a Gaussian product and C either a nuclear position (one-electron case) or the center of a second electron pair's product Gaussian (two-electron case). Angular momentum on each of up to four centers is then built up from V_n by three kinds of linear recursion (Obara-Saika):

  • vertical transfer -- raises angular momentum on the "bra" center while also shifting the Boys-function order n. This is the only recursion that touches the Boys function directly.
  • horizontal transfer -- shifts angular momentum from one center to its partner on the same electron (exact via Gaussian product translation, same recursion used for overlap integrals).
  • electron transfer -- shifts angular momentum from electron 1's pair to electron 2's pair (only needed for the two-electron repulsion integral).

Each recursion has a 2-term memory in its recursion index, so each maps onto jax.lax.scan.

nuclear_attraction

nuclear_attraction(
    g1: GaussianShell3D, g2: GaussianShell3D, nucleus: Array
) -> jax.Array

, shape (L1+1,)3 + (L2+1,)3.

Source code in dense_evolution/native_hf/coulomb.py
@jax.jit
def nuclear_attraction(g1: GaussianShell3D, g2: GaussianShell3D, nucleus: jax.Array) -> jax.Array:
    """<g1| 1/|r - nucleus| |g2>, shape (L1+1,)*3 + (L2+1,)*3."""
    a, A = jnp.asarray(g1.exponent), jnp.asarray(g1.center)
    b, B = jnp.asarray(g2.exponent), jnp.asarray(g2.center)
    p = a + b

    padded_g1 = GaussianShell3D(degree=g1.degree + g2.degree, exponent=a, center=A)
    I = (2.0 * jnp.pi / p) * _hermite_coulomb_tensor(padded_g1, g2, p, nucleus)

    for axis in range(3):
        I = _shift_degree(I, axis, g2.degree + 1, A[axis], B[axis])
        I = I[(slice(0, g1.degree + 1),) * (axis + 1) + (Ellipsis,)]
    return I

electron_repulsion

electron_repulsion(
    g1: GaussianShell3D,
    g2: GaussianShell3D,
    g3: GaussianShell3D,
    g4: GaussianShell3D,
) -> jax.Array

, shape (L1+1,)^3 + (L2+1,)^3 + (L3+1,)^3 + (L4+1,)^3.

Source code in dense_evolution/native_hf/coulomb.py
@jax.jit
def electron_repulsion(g1: GaussianShell3D, g2: GaussianShell3D, g3: GaussianShell3D, g4: GaussianShell3D) -> jax.Array:
    """<g1 g2| 1/r12 |g3 g4>, shape (L1+1,)^3 + (L2+1,)^3 + (L3+1,)^3 + (L4+1,)^3."""
    a, A = jnp.asarray(g1.exponent), jnp.asarray(g1.center)
    b, B = jnp.asarray(g2.exponent), jnp.asarray(g2.center)
    c, C = jnp.asarray(g3.exponent), jnp.asarray(g3.center)
    d, D = jnp.asarray(g4.exponent), jnp.asarray(g4.center)

    padded_g3 = GaussianShell3D(degree=g3.degree + g4.degree, exponent=c, center=C)
    padded_g1 = GaussianShell3D(degree=g1.degree + g2.degree + padded_g3.degree, exponent=a, center=A)

    p, q = a + b, c + d
    scale = (p * q) / (p + q)
    Q = (c * C + d * D) / q
    K34 = jnp.exp(-((c * d) / q) * jnp.sum(jnp.square(C - D)))
    prefactor = 2.0 * jnp.pi ** 2.5 / (p * q * jnp.sqrt(p + q)) * K34

    I = prefactor * _hermite_coulomb_tensor(padded_g1, g2, scale, Q)

    exponents = jnp.array([a, b, c, d])
    for axis in range(3):
        I = _transfer_to_second_electron(
            I, axis, padded_g3.degree + 1, exponents,
            jnp.array([A[axis], B[axis], C[axis], D[axis]]),
        )
        I = I[(slice(0, g1.degree + g2.degree + 1),) * (axis + 1) + (Ellipsis,)]

    for axis in range(3):
        I = _shift_degree(I, axis, g2.degree + 1, A[axis], B[axis])
        I = I[(slice(0, g1.degree + 1),) * (axis + 1) + (Ellipsis,)]

    for axis in range(3):
        I = _shift_degree(I, axis + 3, g4.degree + 1, C[axis], D[axis])
        I = I[(slice(0, g1.degree + 1),) * 3 + (slice(0, g3.degree + 1),) * (axis + 1) + (Ellipsis,)]

    # axes are currently (g1, g3, g2, g4); reorder to (g1, g2, g3, g4)
    return jnp.moveaxis(I, [3, 4, 5], [6, 7, 8])

assembly

Assembling full molecular AO integral matrices from contracted shells.

Each pair (or quartet, for repulsion) of shells contributes a block to the overall S/T/V matrices (or ERI tensor). We loop over shell pairs/quartets in plain Python -- for a minimal basis like STO-3G there are only a handful of shells per atom (Si2/STO-3G: 6 shells total), so this loop is cheap. The sum over primitives within a shell pair/quartet and the slicing down to physical Cartesian components both happen inside a single jax.jit-compiled call per shell pair/quartet: doing that slicing eagerly (one jnp.ndarray.getitem per primitive combination) turned out to cost as much per-call dispatch overhead as PennyLane's own scalar-at-a-time autograd loop, defeating the purpose, so everything from "loop over primitives" to "slice out the physical components" is fused into one compiled program per shell pair/quartet and only that program's output touches eager Python.

quartet_screening_indices

quartet_screening_indices(
    shells: list[ContractedShell],
    screening_tol: float = 1e-12,
) -> list[tuple[int, int, int, int]]

The (i,j,k,l) shell-index quartets build_repulsion_tensor would compute, decided from Schwarz bounds -- split out so a caller differentiating build_repulsion_tensor w.r.t. nuclear positions can compute this ONCE from a concrete (non-traced) geometry and reuse it.

Schwarz screening is a discrete, structural decision (which terms exist in the sum at all), not a smooth function of geometry -- Python control flow like if bound < tol cannot run on a traced value, the same reason any code with data-dependent sparsity can't be differentiated as a black box. Real differentiable quantum chemistry codes handle this the same way: freeze the sparsity pattern from a concrete evaluation, then differentiate a numerical re-evaluation that reuses that fixed pattern. Passing this list's output back into build_repulsion_tensor's quartet_indices argument is that reuse.

Source code in dense_evolution/native_hf/assembly.py
def quartet_screening_indices(shells: list[ContractedShell], screening_tol: float = 1e-12) -> list[tuple[int, int, int, int]]:
    """The (i,j,k,l) shell-index quartets build_repulsion_tensor would
    compute, decided from Schwarz bounds -- split out so a caller
    differentiating build_repulsion_tensor w.r.t. nuclear positions can
    compute this ONCE from a concrete (non-traced) geometry and reuse it.

    Schwarz screening is a discrete, structural decision (which terms
    exist in the sum at all), not a smooth function of geometry -- Python
    control flow like `if bound < tol` cannot run on a traced value, the
    same reason any code with data-dependent sparsity can't be
    differentiated as a black box. Real differentiable quantum chemistry
    codes handle this the same way: freeze the sparsity pattern from a
    concrete evaluation, then differentiate a numerical re-evaluation
    that reuses that fixed pattern. Passing this list's output back into
    build_repulsion_tensor's `quartet_indices` argument is that reuse."""
    max_primitives = max(s.exponents.shape[0] for s in shells)
    schwarz = _shell_pair_schwarz_bounds(shells, max_primitives)
    indices = []
    for i in range(len(shells)):
        for j in range(i + 1):
            ij_index = i * (i + 1) // 2 + j
            for k in range(len(shells)):
                for l in range(k + 1):
                    kl_index = k * (k + 1) // 2 + l
                    if ij_index < kl_index:
                        continue
                    if schwarz[(i, j)] * schwarz[(k, l)] < screening_tol:
                        continue
                    indices.append((i, j, k, l))
    return indices