CASSCF groundwork: revive the Python bindings, add the shared C++ CAS engine and both geometry adapters - #344
susilehtola wants to merge 17 commits into
Conversation
CMake silently discards add_test() calls made in a directory that was reached before testing was enabled -- no warning, no error, the CTestTestfile.cmake for that directory simply is not generated. Since enable_testing() sat below the build subdirectories, any test registered from libhelfem/, libhelfemqc/, python/ or src/ would have vanished without trace; only tests/ worked, because it is added afterwards. Nothing registered tests from those directories yet, so nothing was actually lost. The next commit does, which is how this surfaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
python/bindings.cpp had not compiled since the Armadillo-to-Eigen
migration: it included <armadillo> and libhelfem/include/ArmaEigen.h,
neither of which still exists. HELFEM_PYTHON defaults OFF, so nothing
ever tried. Five accessors had also been renamed underneath it --
get_basis -> make_basis, get_nbf -> nbf, get_lval -> lval,
get_mval -> mval, get_rad_Nel -> rad_Nel.
The port removes more than it adds: the SCF surface is natively
helfem::Matrix now, so the arma round-trip at the numpy boundary was
pure overhead.
python/helfem/pyscf_driver.py was missing entirely -- two of the three
existing tests import install_full_eri, helfem_scf and build_active_eri
from it, so they could not even get past their import line. It is
written here to that contract. The module rests on one identity: HelFEM
exposes no four-index tensor, but coulomb() contracts the second pair,
so feeding it a pair density and contracting the free pair gives MO
integrals directly,
(tu|vw) = C_v^T . coulomb(C_t C_u^T + h.c.) . C_w / 2
at one coulomb() call per pair and no AO->MO transform. With C = I that
extracts the full AO tensor; restricted to an active space it is the
integral list a CASSCF needs. pyscf imports are kept inside the
functions that use them so the tests stay runnable without pyscf.
Measured on He (Nbf=14, lmax=0, nelem=3, nnodes=6, Rmax=20):
J, K rebuilt from the extracted tensor
vs coulomb() / exchange() 2e-16 / 5e-16
8-fold permutational symmetry 2.2e-16
pyscf RHF on these integrals vs HelFEM's own
atomic binary -2.859592542390 vs -2.8595925424
patched-get_jk vs explicit-tensor SCF route 9.3e-15
build_active_eri vs pyscf ao2mo 1.1e-16
CAS(2,1) == RHF 1.0e-14
ninact=1/nact=0 core energy == RHF 8.9e-16
energy re-contracted from the CI RDMs 0.0e+00, Tr(D) = 2
The three tests are now registered with CTest under the label "python"
(4 s total). They are copied into the build tree rather than run where
they sit: Python puts a script's own directory first on sys.path, so
running them in place resolves `import helfem` to the source package,
which holds no extension module, and fails however PYTHONPATH is set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Full 61 = 8 unit + 49 quick integration + No C++ outside |
The CAS energy gradient with respect to an orbital rotation, at fixed CI
coefficients, is g = 2(F - F^T) with the generalized Fock matrix
F[m][i] = 2 ( F^I[m][i] + F^A[m][i] ) i inactive
F[m][t] = sum_u D_tu F^I[m][u] + Q[m][t] t active
Q[m][t] = sum_uvw d_tuvw (mu|vw)
Every term is a coulomb()/exchange() call on a pair density, Q costing one
coulomb() per active (t,u) pair with the 2-RDM slice sum_vw d_tuvw C_v C_w^T
as its argument. As with the active integrals, no AO->MO transform appears
anywhere: the 1- and 2-RDMs plus the existing contractions are the whole
input, which is what makes this portable to the diatomic basis unchanged.
Verified by central-differencing frozen_ci_energy, which rebuilds the CAS
energy through active_hamiltonian and so shares no algebra with the gradient
formula. That separation is deliberate: CLAUDE.md records that a derivative
check differentiating one expression against finite differences of itself
agrees perfectly with a consistently wrong one.
On Be, CAS(2,3) over a 1s core (Nbf=14):
inactive-active worst |fd - analytic| 1.9e-09
inactive-virtual 1.1e-08
active-virtual 1.5e-09
active-active 1.2e-09
unit-norm direction, Richardson to h=0 1.5e-10
CI-relaxed vs frozen-CI gradient 2.5e-11
CAS(4,14) vs FCI in the same space 0.0e+00
Redundant classes are checked too and must match: at frozen CI an
active-active rotation does change the energy, and only becomes redundant
once the CI is allowed to relax. Check 3 is that statement quantitatively --
at a converged CASCI the CI is variational, so the frozen-CI gradient already
equals the relaxed one.
Normalizing the probe direction matters and is worth recording: the
central-difference truncation error scales as ||K||^3 h^2, so an unnormalized
random direction over all 91 pairs disagrees by 6e-2 relative for entirely
uninteresting reasons. A step-size study showed the clean 4.00x-per-halving
signature of O(h^2), which is how that was settled rather than guessed at.
The new test needs pyscf and scipy, unlike the numpy-only DF tests, so
CMakeLists probes for them separately rather than failing on a numpy-only
machine. Python cases: 3 -> 4, total registration 204 -> 205.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: Phase 2, the CASSCF orbital gradient (
|
| check | worst |fd − analytic| |
|---|---|
| inactive–active | 1.9e-09 |
| inactive–virtual | 1.1e-08 |
| active–virtual | 1.5e-09 |
| active–active | 1.2e-09 |
| unit-norm direction, Richardson to h→0 | 1.5e-10 |
| CI-relaxed vs frozen-CI gradient | 2.5e-11 |
| CAS(4,14) vs FCI in the same space | 0.0e+00 |
Redundant classes are checked too and must match: at frozen CI an active–active rotation does change the energy, and only becomes redundant once the CI relaxes. The CI-relaxed row is that statement quantitatively.
One thing worth recording: normalizing the probe direction matters. The central-difference truncation error scales as ‖K‖³h², so an unnormalized random direction over all 91 pairs disagreed by 6e-2 relative — for entirely uninteresting reasons. A step-size study gave the clean 4.00×-per-halving signature of O(h²), which is how that was settled rather than assumed.
The new test needs pyscf and scipy, unlike the numpy-only DF tests, so CMake probes for them separately. Python cases 3 → 4; total registration 204 → 205; ctest -L python 4.5 s, all passing.
…grals Solve the CI at fixed orbitals, build the orbital gradient from the resulting RDMs, step the orbitals, repeat. This is the reference implementation the second-order path will be diffed against, and the first point in the arc that produces a working CASSCF. Verification is the tightest available here: PySCF's mcscf.CASSCF running on HelFEM's own AO integrals against this optimizer on the same integrals. Two independent optimizers, identical input, nothing shared but the integrals -- so unlike a derivative check it cannot be fooled by a consistently wrong energy expression. On Be CAS(2,3) over a 1s core (Nbf=14): HelFEM -14.563849375547 (71 macro, 106 CI solves, |g| 2.3e-07) PySCF -14.563849375707 diff 1.6e-10 Re-referencing is the whole design and was learned the hard way. dE/dx equals the generalized-Fock gradient ONLY at x = 0; away from it the Frechet derivative of the matrix exponential enters. A first attempt handed scipy L-BFGS the gradient from the rotated point under a frozen-reference parametrization -- an inconsistent objective/gradient pair. It thrashed, |g| oscillating between 1e-3 and 2e-2, and crawled to -14.56062 against a true minimum of -14.56385. The gradient was not at fault: re-checking it at a random point well away from the reference (||K|| = 2.1) reproduced finite differences to 3.7e-07 on values of order 10-50. Each macroiteration now works at x = 0 and folds the accepted step into C, with a backtracking line search on the true energy, so the energy falls monotonically. An additional exact redundancy shows up here, beyond the active-active rotations already excluded: the converged solution has an active natural orbital with occupation 0.00000000, so all its 1- and 2-RDM elements vanish, its column of the generalized Fock is identically zero, and every rotation involving it is EXACTLY flat. That is why the two codes agree on the energy to 1.6e-10 while their occupied spaces differ in one direction by 3.9 degrees. The test therefore asserts on the energy and on the core density (which agree to 3.7e-06) and reports the natural occupations, rather than demanding the orbitals match. The second-order solver will meet this as a genuine null space of the orbital Hessian -- and HelFEM's conditioning report, which treats exact zeros in the Hessian diagonal as a bug, will flag it as one. Python cases: 4 -> 5, total registration 205 -> 206. ctest -L python, 9.5 s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: Phase 3, two-step CASSCF (
|
| HelFEM two-step CASSCF | −14.563849375547 (71 macro, 106 CI solves, |g| 2.3e-07) |
PySCF mcscf.CASSCF |
−14.563849375707 |
| difference | 1.6e-10 |
Re-referencing, learned the hard way
dE/dx equals the generalized-Fock gradient only at x = 0 — away from it the Fréchet derivative of the matrix exponential enters. My first attempt handed scipy L-BFGS the gradient from the rotated point under a frozen-reference parametrization, an inconsistent objective/gradient pair. It thrashed (‖g‖ oscillating between 1e-3 and 2e-2) and crawled to −14.56062 against a true minimum of −14.56385.
The gradient was not at fault, and I checked that rather than assuming it: re-verified at a random point well away from the reference (‖K‖ = 2.1), it reproduced finite differences to 3.7e-07 on values of order 10–50. Each macroiteration now works at x = 0 and folds the accepted step into C, with a backtracking line search on the true energy — monotone by construction.
A second exact redundancy
Worth flagging for the second-order path. Beyond the active-active rotations already excluded, this solution has an active natural orbital with occupation 0.00000000: all its 1- and 2-RDM elements vanish, its column of the generalized Fock is identically zero, and every rotation involving it is exactly flat.
That is why the two codes agree on the energy to 1.6e-10 while their occupied spaces differ in one direction by 3.9°. Not a convergence failure — they stopped at different points along a genuinely flat direction.
So the test asserts on the energy and on the core density (which agree to 3.7e-06) and reports the natural occupations, rather than demanding the orbitals match. A CASSCF code comparison that checks orbitals will produce false failures.
The consequence for Phase 4: this is a real null space of the orbital Hessian that has to be projected out, and HelFEM's conditioning report — which treats exact zeros in the Hessian diagonal as a bug — will flag it. Correct for an SCF; expected for a CAS.
Python cases 4 → 5, total registration 205 → 206, ctest -L python 9.5 s all passing.
Orbital rotations and CI coefficients optimized together rather than alternating. All four Hessian blocks are analytic and built from the same coulomb()/exchange() pair-density primitive as the integrals and the gradient, so no AO->MO transform appears anywhere and the construction carries to the diatomic basis unchanged: H_kk d/dt of the generalized Fock under a one-index transformation H_ss 2 P_perp (H - E) P_perp -- a sigma-vector H_ks the generalized Fock evaluated on TRANSITION RDMs H_sk = H_ks^T On Be CAS(2,3) over a 1s core (Nbf=14), against the two-step reference: two-step -14.563849375547 71 macro, |g| 2.3e-07 coupled -14.563849376675 22 macro, |g| 2.0e-08 closing |g| = 1.4e-04 -> 7.5e-08 -> 2.0e-08, the quadratic behaviour a correct second-order method should show. The two agree to 1.1e-09. Three things had to be got right rather than assumed, each caught by a check that could fail. The transition case is NOT the state case with different numbers substituted. For two states of the same CAS, <I|E_ij|0> = delta_ij <I|0> = 0 over the inactive block, so the inactive-Fock contribution to the inactive columns drops out while the active columns keep it. generalized_fock gains core_occ: 2 for a state density, 0 for a transition density. Differentiating the gradient walks exp(tK')exp(sK) while the Hessian is defined on exp(sK + tK'), so by BCH the two differ by (1/2) g . [K', K] and agree only where the gradient vanishes. Rather than dodge this by testing at a stationary point, the test predicts the discrepancy: the antisymmetric part of the raw derivative reproduces the commutator to five significant figures. A spurious 1/2 in the kappa-kappa assembly halved that block -- the scalar form contracts the full antisymmetric matrix and double-counts each pair, extracting pair entries directly does not. It did not look like a factor error: the optimizer merely stalled 8.8e-04 high. Splitting the Hessian check by segment identified it immediately as ratio exactly 2.000000 on the orbital block against 1.000000 on the CI block. Step control needs the null space PROJECTED OUT, not shifted. A CAS Hessian is genuinely singular beyond the active-active rotations already excluded: an active orbital with zero natural occupation makes every rotation touching it exactly flat, and 10 of the 51 modes here have |eigenvalue| < 1e-6 carrying a gradient of 1.7e-09. A Levenberg shift leaves those modes with |g/w| up to 8e-02 from dividing ~0 by ~0; the trust radius then clips the whole step and throttles the informative directions, stalling at |g| ~ 1e-02 while looking merely slow. Python cases: 5 -> 6, total registration 206 -> 207. ctest -L python, 23 s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: Phase 4, fully coupled (κ, c) CASSCF (
|
| block | how |
|---|---|
H_κκ |
d/dt of the generalized Fock under a one-index transformation |
H_ss |
2 P⊥(H−E)P⊥ — a σ-vector |
H_κs |
the generalized Fock evaluated on transition RDMs |
H_sκ |
= H_κsᵀ |
On Be CAS(2,3) over a 1s core (Nbf=14):
| E | macro | final ‖g‖ | |
|---|---|---|---|
| two-step | −14.563849375547 | 71 | 2.3e-07 |
| coupled | −14.563849376675 | 22 | 2.0e-08 |
Closing iterations go ‖g‖ = 1.4e-04 → 7.5e-08 → 2.0e-08 — the quadratic behaviour a correct second-order method should show. The two agree to 1.1e-09.
Three things that had to be got right rather than assumed
The transition case is not the state case with different numbers substituted. For two states of the same CAS, ⟨I|Ê_ij|0⟩ = δ_ij⟨I|0⟩ = 0 over the inactive block, so the inactive-Fock contribution to the inactive columns drops out while the active columns keep it. generalized_fock gains core_occ: 2 for a state density, 0 for a transition density.
BCH ordering. Differentiating the gradient walks exp(tK')exp(sK) while the Hessian is defined on exp(sK+tK'), so the two differ by ½ g·[K',K] and agree only where the gradient vanishes. Rather than dodge this by testing at a stationary point — where it cannot appear — the test predicts the discrepancy, and the antisymmetric part of the raw derivative reproduces the commutator to five significant figures.
A spurious ½ halved the κκ block. The scalar form contracts the full antisymmetric matrix and double-counts each pair; extracting pair entries directly does not. It did not present as a factor error — the optimizer merely stalled 8.8e-04 high, which looks like a hundred other things. Splitting the Hessian check by segment identified it immediately: ratio exactly 2.000000 on the orbital block against 1.000000 on the CI block.
The null space must be projected out, not shifted
Worth recording, because it will recur in the C++ solver. A CAS Hessian is genuinely singular beyond the active-active rotations already excluded: an active orbital with zero natural occupation makes every rotation touching it exactly flat. Here 10 of 51 modes have |eigenvalue| < 1e-6, carrying a gradient of 1.7e-09 — i.e. nothing.
A Levenberg shift does not handle that. It leaves those modes with |g/w| up to 8e-02 from dividing ~0 by ~0; the trust radius then clips the whole step and throttles the informative directions. The optimizer stalls at ‖g‖ ~ 1e-02 while looking merely slow. Dropping the modes costs nothing, since the energy does not depend on them.
Python cases 5 → 6, total registration 206 → 207, ctest -L python 23 s all passing.
The first C++ piece of the CASSCF work, and the part that needs no CI
solver. src/general/cas_integrals.{h,cpp} holds the active-space
Hamiltonian, the generalized Fock matrix and the orbital gradient;
src/atomic/cas_engine.h is the whole atomic side of it.
The split is what the Python reference established: none of this needs an
AO->MO transform, because coulomb() contracts the second index pair, so a
pair density in and a contraction of the free pair out gives MO integrals
directly. A geometry therefore only has to supply J, K and hcore --
cas::JKProvider, three virtuals -- and the algebra lives once. The diatomic
adapter will be the same size as the atomic one.
Validated against python/helfem/pyscf_driver.py, which is itself checked
against PySCF (active integrals to 1e-16 vs ao2mo, CASSCF to 1.6e-10 vs
mcscf.CASSCF on the same integrals). The two share no code, only the
underlying J/K builds, so agreement is a real check rather than a tautology.
Orbitals come from the core guess -- generalized eigenvectors of (hcore, S),
deterministic in both languages, with an explicit phase convention since an
eigenvector is only defined up to sign. Every quantity matches to ~1e-13,
the print precision of the reference:
E_inactive -13.487633038924 trace(h_eff) -0.498155424803
sum(eri) 2.508862565558 sum(F) -10.092279000769
norm(g) 5.799542776404
The exchange convention was the thing most likely to break and is worth
stating. HelFEM's exchange() returns the signed contribution ADDED to a SPIN
channel's Fock matrix (src/atomic/main.cpp:383, Exx = 0.5 tr(P_spin K_spin)),
while PySCF wants a positive K subtracted from a TOTAL density -- the two
differ by a sign AND by a factor of two. cas::closed_shell_veff is the single
place that reconciles them, so nothing else has to think about it, and
E_inactive above is what proves it right.
generalized_fock takes core_occ because a transition density is not a state
density with different numbers substituted: for two states of the same CAS,
<I|E_ij|0> = delta_ij <I|0> = 0 over the inactive block, so the
inactive-Fock term drops out of the inactive columns while the active columns
keep it. The test asserts exactly that -- core_occ=0 must leave the active
columns alone and must change the inactive ones.
Unit tests: 8 -> 9, total registration 207 -> 208. cas_integrals_test 0.04 s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: the C++ CAS integral engine (
|
E_inactive |
−13.487633038924 |
trace(h_eff) |
−0.498155424803 |
sum(eri) |
2.508862565558 |
sum(F) |
−10.092279000769 |
norm(g) |
5.799542776404 |
The exchange convention
The thing most likely to break, and worth stating. HelFEM's exchange() returns the signed contribution added to a spin channel's Fock matrix (src/atomic/main.cpp:383, Exx = 0.5 tr(P_spin K_spin)), while PySCF wants a positive K subtracted from a total density. The two differ by a sign and by a factor of two. cas::closed_shell_veff is the single place reconciling them, so nothing else has to think about it — and E_inactive above is what proves it right.
core_occ
generalized_fock takes it because a transition density is not a state density with different numbers substituted: for two states of the same CAS, ⟨I|Ê_ij|0⟩ = δ_ij⟨I|0⟩ = 0 over the inactive block, so the inactive-Fock term drops from the inactive columns while the active columns keep it. The test asserts exactly that — core_occ=0 must leave the active columns alone and must change the inactive ones.
Unit tests 8 → 9, total registration 207 → 208; cas_integrals_test runs in 0.04 s. Full unit + python labels: 15/15 passing.
fock_response_kappa is d/dt of the generalized Fock under a one-index transformation (dC = C dkappa): every piece of the Fock is a coulomb/exchange call on a density built from C, so its derivative is the same calls on the transformed densities plus contraction terms. Still no new integral machinery, and still nothing an AO->MO transform would help with. Two things about the ordering are worth stating, because getting either wrong is silent. hess_kappa_kappa_raw is NOT the Hessian. Differentiating the gradient walks a product of exponentials exp(t K')exp(s K), while the Hessian is defined on exp(sK + tK'); by Baker-Campbell-Hausdorff the two differ by (1/2) g.[K',K] and agree only where the gradient vanishes. hess_kappa_kappa symmetrises, which removes the term exactly. The test does not dodge this by working at a stationary point where it cannot appear -- it PREDICTS the discrepancy, and the antisymmetric part of the raw derivative reproduces the commutator to 2.1e-15. The contraction carries two separate halvings: one for the symmetrisation, one because contracting full antisymmetric matrices counts every rotation pair twice. Dropping the second halves the block, which does not present as a factor error -- in the Python reference it showed up only as an optimizer stalling 8.8e-04 high. The test gains its own finite differences, and needs no CI solver to do it: frozen_ci_energy is E_inactive + sum h_eff D + sum eri d / 2, so a fixed RDM pair is enough. It is rebuilt through active_hamiltonian and shares no algebra with the gradient or the Hessian, which is what makes differencing it a check rather than a re-expansion. vs the Python reference sum(dF), norm(hess_kk_raw), elements ~5e-13 K1 . gradient, Richardson 9.1e-11 K1 . H_kk . K2, four-point 7.7e-08 BCH asymmetry vs (1/2)g.[K',K] 2.1e-15 A single central difference of the gradient is truncation-limited at 3.0e-07 for h = 2e-4 and says nothing about the formula; the test asserts the Richardson-extrapolated value AND that the error falls fourfold when h halves, so the O(h^2) signature has to be present rather than a loose tolerance absorbing a real error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: the C++ κκ Hessian block (
|
| check | |
|---|---|
vs Python reference (sum(dF), norm(hess_kk_raw), elements) |
~5e-13 |
K1·gradient, Richardson |
9.1e-11 |
K1·H_κκ·K2, four-point |
7.7e-08 |
BCH asymmetry vs ½g·[K',K] |
2.1e-15 |
One note on method. A single central difference of the gradient is truncation-limited at 3.0e-07 for h=2e-4 and says nothing about the formula — my first tolerance flagged it as a failure. Rather than loosen the tolerance I checked the signature: the error falls by exactly 4.00× per halving, the O(h²) fingerprint. The test now asserts the Richardson-extrapolated value and that fourfold fall, so a real error can't hide behind a loose bound later.
unit label: 9/9 passing.
…ing() Both sides changed the same lines for different reasons, and both reasons stand, so the resolution keeps each. master (PR #346) gated enable_testing() and add_subdirectory(tests) behind HELFEM_BUILD_TESTS, because neither is HelFEM's to do unasked when another build consumes it. This branch had moved enable_testing() ABOVE the build subdirectories, because CMake silently drops add_test() calls made in a directory reached before testing was enabled -- which is why python/'s cases were being generated into nothing. Combined: the option is declared and enable_testing() called early, still before add_subdirectory(python), while add_subdirectory(tests) stays where it was. python/CMakeLists.txt returns early unless HELFEM_BUILD_TESTS, since it is reached first and registers its own cases; without that a consumer building the bindings with tests off would still inherit them. Registration across the four combinations: defaults 202 HELFEM_BUILD_TESTS=OFF 0 HELFEM_PYTHON=ON 208 HELFEM_PYTHON=ON BUILD_TESTS=OFF 0 Build clean, unit + python labels 15/15. CLAUDE.md gains the option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
converge_block's cap warning computed its relative change as (cur - prev) AFTER prev had been overwritten with cur three lines above, so it printed "relative change still 0.000e+00" unconditionally -- however badly a block was actually converging. Report prevdiff, the last difference the convergence test itself saw, and say so explicitly in the one case where the seed order already exceeded the cap and no difference was ever computed. With that fixed the warning immediately reported 1.191e-01 on diatomic-H2-hf-r, a CI case. Real, but harmless: radial_integral(-1,0) is the integral of B_i B_j / sinh(mu), which diverges logarithmically at mu = 0. The convergence history shows the block MAGNITUDE growing with the quadrature order (5.57 -> 6.96 -> 7.90) while every other block reaches 1e-15 at n=160, and the growth is exactly logarithmic: the 320 -> 512 step is a factor 1.6, and 1.39 * log(512/320)/log(2) = 0.94, the measured difference to two digits. The divergence is confined to the row and column of the one basis function that is non-zero at mu = 0 -- and that is exactly the function Nbf() drops from every m != 0 shell, which is the only place the integral is used. It is discarded before the matrix is ever seen, which is WHY it is dropped. So converge_block gains an nskip parameter excluding those entries from the convergence test (the block is still returned whole), and radial_integral passes it for m < 0. H2 is unchanged at -1.1336295702, the HF limit to all printed digits, and the warning no longer fires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DiatomicCASEngine is the diatomic half of cas::JKProvider, and is as small as the atomic one for the same reason: the CAS machinery only ever asks a geometry for J, K and hcore, so the algebra stays in src/general/cas_integrals.cpp, where it is already validated against the Python/PySCF reference. The one thing it has to police that the atomic engine does not is TwoDBasis::absm_symmetric. With it set, exchange() builds only the m >= 0 half of K and mirrors the rest -- exact for a density symmetric under m -> -m, wrong otherwise. A CAS forms PAIR densities C_t C_u^T between individual active orbitals, and one between the +m and -m partners of a pi shell carries dm = 2. Test 3 builds exactly that density and measures the damage: |K_general - K_mirrored|max equals |K_general|max, i.e. the mirror overwrites the blocks that carry the whole result, with nothing to flag it at runtime. On a genuinely m-symmetric density the same comparison is 0.0 to the bit. The flag is refused rather than worked around. Note the scope of that, because the obvious reading is too strong: it is a statement about exchange(), not about --symmetry. coulomb() never consults the flag, it defaults to false, and the AO basis is identical for every --symmetry value -- only the orthonormalization blocking and that flag differ. The CAS integrals are symmetry-agnostic, and a --symmetry=3 reference is usable input provided the flag is cleared first. What --symmetry=3 cannot carry is the active SPACE, since an |m| > 0 block there is one set of radial coefficients standing for two spatial orbitals. That is a separate question from the integrals. The first draft of test 3 indexed angular shells with a fixed stride Nbf/Nang. That is wrong -- an m != 0 shell drops its first radial function, so Nbf is 101 here rather than 7 * 15 -- and the test caught it by failing its own converse check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # src/CMakeLists.txt # tests/CMakeLists.txt
The diatomic CAS test only checked STRUCTURE -- E_inactive == 0 at
ninact = 0, h_eff == C^T h C, permutational symmetry. A tensor that is
self-consistently wrong satisfies all three. The atomic side had a real
numerical cross-check through PySCF; diatomic had none, and only the atomic
basis is exposed to Python.
So rebuild J from the extracted (tu|vw) and compare against coulomb() called
directly -- code paths sharing nothing with active_eri's pair-density
extraction. It agrees to 2.8e-16. The check runs on a 6-orbital subset rather
than all 101, which is exact rather than approximate: coulomb and exchange are
linear and the density is confined to the active space, so no Nbf^4 tensor
(836 MB here) is needed.
Trying the same for K turned up a limitation worth recording. active_eri
stores
eri[t][u][v][w] = ( (tu|vw) + (ut|vw) ) / 2,
because coulomb() takes a HERMITIAN density and the pair density C_t C_u^T is
not one -- it is symmetrised to P + P^T. For REAL orbitals the two orderings
coincide and nothing is lost. For COMPLEX orbitals -- any m != 0 -- they
differ, the antisymmetric part is discarded, and K cannot be rebuilt from what
is stored.
Measured, rebuilding K from the tensor:
atomic lmax=0 3.3e-16 dJ 8.9e-16
atomic lmax=1 1.0e-01 dJ 8.9e-16
atomic lmax=2 1.1e-01 dJ 1.6e-15
diatomic lmax=1-5 2.0e-02 to 4.6e-02
Exact at lmax = 0 and wrong above it, in BOTH geometries. It is therefore not
a diatomic issue and not an exchange() issue; it is what the stored tensor can
represent. J is unaffected at every lmax because it contracts a Hermitian
density, and that Hermiticity is precisely the symmetrisation.
This matters beyond the test. The CAS energy is E2 = sum d_tuvw (tu|vw) / 2,
so symmetrising the integral leaves an error
sum [d_tuvw - d_utvw] (tu|vw) / 4, which vanishes only if the 2-RDM is t <-> u
symmetric -- true for real orbitals, not for complex ones. Every CAS check so
far (the PySCF cross-checks, the C++ reference values) ran at lmax = 0, so
none of it covers this. The cases it bears on are the motivating ones: a
pi+/pi- active space at --symmetry=1, and any atomic active space with l > 0.
Also corrected: the test used to print "8-fold permutational symmetry of eri"
and pass at 1.1e-16. The t <-> u half of that holds BY CONSTRUCTION, since
active_eri writes one computed value into both slots -- it reported
bookkeeping as physics, and it is exactly the check that would let a
symmetrisation error through. The (tu) <-> (vw) half is genuine, those coming
from separate coulomb() calls. Relabelled and annotated accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
active_eri passed coulomb() the SYMMETRISED pair density P + P^T and halved
the result, storing ((tu|vw) + (ut|vw))/2. That looks harmless -- a physical
density IS symmetric -- but a CAS pair density is a TRANSITION density, and
its antisymmetric part is a purely imaginary density (rho* = -rho) carrying
genuine information. For REAL orbitals the two orderings coincide and nothing
is lost, which is why every m = 0 check passed; for COMPLEX orbitals, any
m != 0, the imaginary channel was discarded.
The channel was there the whole time. coulomb() and exchange() take arbitrary
square input and are bit-exactly linear -- measured
|J(P) + J(P^T) - J(P+P^T)| = 0.00e+00 at every lmax -- and neither symmetrises
its argument: |J(P) - J(P^T)| is 5.6e-02 at atomic lmax=1, where the
antisymmetric part of J(P) is as large as the symmetric part. At lmax=0 that
part is 6.5e-19, i.e. genuinely zero, which is exactly why the bug hid. So no
new integral path is needed; the fix is to pass the density bare.
Cost is unchanged. (ab|cd) = (ba|dc) makes the (u,t) block the (t,u) block
transposed in (v,w), so it is still n(n+1)/2 coulomb calls.
Rebuilding J and K from the tensor and comparing against coulomb()/exchange()
called directly -- code paths sharing nothing with the extraction:
before after
J (tu|vw) 2.8e-16 2.8e-16
K (tw|vu) 4.5e-02 2.2e-16
Note the K ordering. K_tu = sum_vw (tw|vu) P_vw; the familiar (tv|uw) is a
REAL-orbital form, exact at lmax=0 and wrong by 2.0e-01 at atomic lmax=1. The
earlier version of this test used (tv|uw) and so agreed at m = 0 and disagreed
everywhere else, which pointed at the integrals rather than at the formula.
The permutational-symmetry check is corrected with it. It asserted
(ab|cd) = (ba|cd), a real-orbital symmetry that passed only because
active_eri imposed it -- a check on the bookkeeping reported as a check on
physics. What holds in any basis is (ab|cd) = (cd|ab) and (ab|cd) = (ba|dc);
those are asserted now, and the absence of (ab|cd) = (ba|cd) is asserted too,
so reintroducing the symmetrisation fails the suite instead of passing it.
The atomic reference values are untouched: they are PySCF cross-checks on an
lmax = 0 basis, where the symmetrised and true tensors are identical.
unit 11/11, python 6/6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
active_eri was not the only place passing coulomb() a symmetrised argument on
a real-orbital premise. The same three lines appear in generalized_fock and,
twice, in fock_response_kappa:
jk.coulomb(0.5 * (Ptu + Ptu.transpose()))
with Ptu = Ca d_tu Ca^T the 2-RDM-weighted density. The justification written
there -- "(mn|vw) is symmetric in (v, w), so symmetrising the contraction
argument is exact" -- is true only for REAL orbitals. For complex ones, any
m != 0, it silently drops sum_vw (d_tuvw - d_tuwv) (mu|vw) / 2, and the 2-RDM
is not symmetric in its last two indices. Passed bare; coulomb() takes
arbitrary square input and is exactly linear, so no argument here needs to be
symmetric.
The other call sites are fine and stay as they are: PI, PA, dPI and dPA are
physical densities, symmetric by construction rather than by assumption.
What this cost, measured on H2 with a pi active space (m = +-1) by
differencing frozen_ci_energy -- which reaches the integrals through the eri
tensor -- against the analytic gradient, which reaches them through coulomb
contractions:
K . gradient, Richardson vs analytic before 3.9e-06 after 1.7e-11
central-diff error ratio h -> h/2 before 1.00 after 2.71
The ratio is the diagnosis. A gradient that is merely truncation-limited has
its error fall fourfold per halving; one that sits at 1.00 has a constant
offset, i.e. it is differentiating the wrong expression. 3.9e-06 is also small
enough that a trust-region optimiser would converge contentedly to the wrong
stationary point -- the failure mode CLAUDE.md notes --sotest cannot see,
since that differentiates one energy expression against finite differences of
itself.
The check is new here, and it was verified to DISCRIMINATE before being kept:
reverting the fix makes both of its assertions fail. The atomic gradient and
Hessian checks could not have caught this -- they run at lmax = 0, where the
orbitals are real and index ordering, conjugation and symmetrisation all
coincide.
unit 11/11, python 6/6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # src/CMakeLists.txt # src/diatomic/basis.cpp # tests/CMakeLists.txt
# Conflicts: # src/CMakeLists.txt # tests/CMakeLists.txt
|
Split into two independent PRs, as discussed:
No commit touched both halves, so the split is clean. |
Groundwork for CASSCF. Started as a Python-bindings repair and has grown into the geometry-agnostic C++ CAS integral engine plus both adapters. Draft because the CI solver it feeds depends on
reci, which is not public yet — everything here is the orbital side, which does not.The branch name is now too narrow for its contents; happy to split the C++ CAS commits (
e44e155,43a3445,a4bfd99) onto their own branch if you would rather review them apart from the Python work.The bindings were dead code
python/bindings.cpphas not compiled since the Armadillo→Eigen migration — it includes<armadillo>andlibhelfem/include/ArmaEigen.h, neither of which still exists.HELFEM_PYTHONdefaults OFF, so nothing ever tried. Five accessors had also been renamed underneath it:get_basis→make_basis,get_nbf→nbf,get_lval→lval,get_mval→mval,get_rad_Nel→rad_Nel.The port removes more than it adds (−46/+43 in
bindings.cpp): the SCF surface returnshelfem::Matrixnatively now, so the Armadillo round-trip at the numpy boundary was pure overhead.helfem.pyscf_driverwas missingpython/test_radial_df_factors.pyimportsinstall_full_eri,helfem_scfandbuild_active_erifromhelfem.pyscf_driver— a module that is not in the tree. Both DF tests failed at their import line. This writes it to that contract.The module rests on one identity. HelFEM exposes no four-index tensor, but
coulombcontracts the second pair, so feeding it a pair density and contracting the free pair yields MO integrals directly:One
coulombcall per pair, no AO→MO transform. WithC = Ithat extracts the full AO tensor (tests and small bases); restricted to an active space it is precisely the integral list a CASSCF needs — which is the point, and why no new integral code is required for it. pyscf imports sit inside the functions that use them, so the tests stay runnable on a machine without pyscf.Tests
All three Python tests now pass, having previously been unable to import:
test_smoketest_radial_df_factorstest_radial_df_exhaustiveRegistered with CTest under the label
python, 4 s total:ctest -L python. Total registration goes 201 → 204; nothing else moved.They are copied into the build tree rather than run where they sit. Python puts a script's own directory first on
sys.path, so running them in place resolvesimport helfemto the source package — which holds no extension module — and fails howeverPYTHONPATHis set.A latent CMake bug, fixed first
enable_testing()sat below the build subdirectories. CMake silently discardsadd_test()calls made in a directory reached before testing was enabled — no warning, no error, the directory'sCTestTestfile.cmakesimply is not generated. Any test registered fromlibhelfem/,libhelfemqc/,python/orsrc/would have vanished without trace. Nothing registered tests there yet, so nothing was lost; registering the Python tests is how it surfaced. Moved to its own commit since it is independent of the rest.Measured
On He (Nbf=14, lmax=0, nelem=3, nnodes=6, Rmax=20):
coulomb()/exchange()atomicbinaryget_jkvs explicit-tensor SCF routebuild_active_erivs pyscfao2moninact=1/nact=0core energy == RHFThe last row is the identity a CI solver's RDM contract has to satisfy, so it is confirmed on HelFEM's side before any solver is wired in.
Full
ctestquick tier atOMP_NUM_THREADS=1: see the comment below.The shared C++ CAS engine
src/general/cas_integrals.{h,cpp}carries the whole algebra once, geometry-agnostically. A geometry supplies exactly three things —coulomb,exchange,hcore— throughcas::JKProvider, and everything else (inactive Fock, active ERIs, generalized Fock, orbital gradient, Fock response, the κκ Hessian block) is built on top of the same pair-density identity the Python driver uses. So an adapter is ~60 lines and no new integral code exists in either geometry.src/general/cas_integrals_test.cpppins it against the Python/PySCF reference at deterministic core-guess orbitals, agreeing to ~1e-13 acrossE_inactive,h_eff, the ERI list, the generalized Fock, the gradient norm and the κκ Hessian.One convention needed reconciling: HelFEM's
exchange()returns the signed contribution added to a spin channel's Fock matrix, where PySCF wants a positive K subtracted from a total density — a sign and a factor of two, which is whycas::closed_shell_veffiscoulomb(P) + exchange(P/2)rather than the textbook expression.The diatomic adapter, and the flag it refuses
DiatomicCASEngine(src/diatomic/cas_engine.h) throws if the basis hasabsm_symmetricset. With that flag,exchange()builds only them ≥ 0half of K and mirrors the rest — exact for a density symmetric underm → −m, wrong otherwise. A CAS forms pair densitiesC_t C_uᵀbetween individual active orbitals, and one between the +m and −m partners of a π shell carriesΔm = 2.src/diatomic/cas_diatomic_test.cppbuilds exactly that density and measures the damage rather than asserting it:absm_symmetricbasisE_inactive == 0atninact=0h_eff == CᵀhCatninact=0(tu|vw)|K_general − K_mirrored|max|K_general|maxThe mirror overwrites the blocks carrying the whole result, with nothing to flag it at runtime — so the refusal is necessary, not defensive.
Scope, because the obvious reading is too strong: this is a statement about
exchange(), not about--symmetry.coulomb()never consults the flag, it defaults tofalse, and the AO basis is identical for every--symmetryvalue — only the orthonormalization blocking and that flag differ. The CAS integrals are symmetry-agnostic, and a--symmetry=3reference is usable input provided the flag is cleared. What--symmetry=3cannot carry is the active space, since an|m| > 0block there is one set of radial coefficients standing for two spatial orbitals; that is a separate question from the integrals.An unrelated diatomic fix, surfaced by that test (
d8f6af8)converge_block's quadrature-cap warning computed its relative change as(cur - prev)afterprevhad been overwritten withcurthree lines above, so it printedrelative change still 0.000e+00unconditionally — useless since it was written.Fixing that immediately exposed a second thing: the warning fires on
diatomic-H2-hf-r, a CI case, reporting 1.191e-01. Real but harmless.radial_integral(-1,0)is ∫BᵢBⱼ/sinh(μ)dμ, which diverges logarithmically at μ=0 — the block magnitude grows with quadrature order (5.57 → 6.96 → 7.90) while every other block reaches 1e-15 at n=160, and the growth is exactly logarithmic (the 320→512 step is a factor 1.6, and 1.39·log(512/320)/log(2) = 0.94, the measured difference). The divergence is confined to the row and column of the one basis function non-zero at μ=0 — which is exactly the functionNbf()drops from everym ≠ 0shell, the only consumer. It is discarded before the matrix is seen, which is why it is dropped.So
converge_blockgained annskipparameter excluding those entries from the convergence test (the block is still returned whole). H2 is unchanged at −1.1336295702, the HF limit to all printed digits, and the warning no longer fires.This commit touches only
basis.cppand is independent of the CAS work — it cherry-picks ontomasteron its own.🤖 Generated with Claude Code