Design — native vibe-qc GDF (Method 1 of the periodic JK roadmap)¶
Status (2026-05-09, feature/native-gdf-chain): in progress. Slice 1 (C++ periodic 2c/3c ERI kernels) and slice 2 (Python aux loader
Cholesky-fitted Lpq) have shipped on the
feature/native-gdf-chainbranch. Slice 3 (modrho compensation) is empirically confirmed mandatory — see “Slice 3 — modrho compensation is required” below. Slices 4–8 (V_ne via aux, driver rewrite, parity sweep, docs) are queued behind slice 3.
Goal: drop the PySCF dependency from the GDF spike. Build the Lpq tensor entirely from vibe-qc’s libint pipeline + an in-house auxiliary basis manager.
What Lpq is, recap¶
M_PQ = (P|Q)_periodic ← 2c periodic ERI on aux basis
(μν|P)_pp = Σ_T (μ_0 ν_0 | P_T) ← 3c periodic ERI w/ ket image sum
Lpq[L, μ, ν] = Σ_P (μν|P)_pp · M^{-1/2}_{PL} (Cholesky factor)
Then for closed-shell RHF (or KS):
ρ_L = Σ_λσ Lpq[L, λ, σ] · D_λσ
J_μν = Σ_L Lpq[L, μ, ν] · ρ_L
K_μν = Σ_L (Lpq[L] @ D @ Lpq[L].T)_μν
F_2e = J − ½ K (+ Madelung correction for exxdiv='ewald')
Pieces vibe-qc needs to add¶
1. C++ — 3c and 2c molecular ERI engines¶
Wraps libint2’s existing 3-center and 2-center engines.
cpp/include/vibeqc/aux_eri.hpp (new):
// 3c molecular: (μν|P) tensor (n_ao, n_ao, n_aux), no lattice sum.
Eigen::Tensor<double, 3> compute_3c_eri(
const BasisSet& ao_basis,
const BasisSet& aux_basis);
// 2c molecular: (P|Q) matrix (n_aux, n_aux), no lattice sum.
Eigen::MatrixXd compute_2c_eri(const BasisSet& aux_basis);
cpp/src/aux_eri.cpp:
Use
libint2::Operator::coulombwithEngine::compute(s1, s2, s3)for 3c andEngine::compute(s1, s2)for 2c.Mirror the molecular
compute_erialready incpp/src/integrals.cppfor the threading + buffer layout.Output is ROW-major libint AO order; permutation to PySCF order (if needed) lives in Python.
2. C++ — periodic versions¶
cpp/include/vibeqc/aux_eri.hpp:
// Periodic 3c: Σ_T (μ_0 ν_0 | P_T) tensor (n_ao, n_ao, n_aux).
Eigen::Tensor<double, 3> compute_3c_eri_lattice(
const BasisSet& ao_basis,
const BasisSet& aux_basis,
const PeriodicSystem& system,
const LatticeSumOptions& opts);
// Periodic 2c: Σ_T (P_0 | Q_T) matrix (n_aux, n_aux).
Eigen::MatrixXd compute_2c_eri_lattice(
const BasisSet& aux_basis,
const PeriodicSystem& system,
const LatticeSumOptions& opts);
Implementation: mirror cpp/src/lattice_integrals.cpp cell loops with
shifted shells, accumulate. Schwarz screening on 3c can prune many
quartet calls.
For the long-range part of the periodic 2c metric (where the 1/r
sum diverges in real space), apply Ewald-style splitting:
(P|Q)_periodic = (P|Q)_SR(ω) + (P|Q)_LR(ω)
where SR is real-space erfc(ω)/r and LR is reciprocal-space erf(ω)/r.
The LR part is a small G-mesh sum, NOT an FFT (the aux basis is
small enough that direct G-summation works).
3. C++ — pybind11 bindings¶
Add to cpp/src/bindings.cpp:
m.def("compute_3c_eri", &compute_3c_eri,
"AO 3-center ERI tensor (μν|P).");
m.def("compute_2c_eri", &compute_2c_eri,
"AO 2-center ERI matrix M_PQ = (P|Q).");
m.def("compute_3c_eri_lattice", &compute_3c_eri_lattice,
"Periodic 3-center ERI tensor with image sum on the ket.");
m.def("compute_2c_eri_lattice", &compute_2c_eri_lattice,
"Periodic 2-center ERI metric matrix.");
4. Python — auxiliary basis manager¶
python/vibeqc/aux_basis.py (new):
def make_aux_basis_set(ao_basis: BasisSet, *, aux_name: Optional[str] = None,
drop_eta: float = 0.0) -> BasisSet:
"""Build an auxiliary BasisSet for an AO basis.
Mirrors PySCF's pyscf.df.addons.predefined_auxbasis +
pyscf.pbc.df.df.make_modrho_basis:
- If aux_name is None, look up the standard JKfit aux for the
AO basis (sto-3g → universal-jkfit, def2-tzvp → def2-jkfit, ...).
- If aux_name is a string, load it from the libint .g94 path.
- Drop primitives with exponents below drop_eta to avoid
linear-dependent metric (matches PySCF make_modrho_basis).
- Renormalize aux primitives so the monopole moment ∫ χ_P dr =
√(1/(4π)). This convention simplifies the compensated-charge
algebra (see PySCF make_modrho_basis docstring).
"""
...
Reference: pyscf/pbc/df/df.py:make_modrho_basis (lines 64–120).
5. Python — Lpq builder¶
In python/vibeqc/periodic_rhf_gdf.py, replace
_lpq_tensor_from_pyscf with:
def _lpq_tensor_native(system, ao_basis, *, aux_name=None,
lat_opts=None, linear_dep_thr=1e-9):
"""Build Lpq(L, μ, ν) entirely with vibe-qc machinery.
1. Build aux basis from ao_basis (or load aux_name).
2. Compute periodic 2c metric: M[P, Q] = Σ_T (P_0 | Q_T)
via compute_2c_eri_lattice (or with Ewald split if needed).
3. Compute periodic 3c: T[μ, ν, P] = Σ_T (μ_0 ν_0 | P_T)
via compute_3c_eri_lattice.
4. Cholesky factor M = L L^T (Lehtola-style pivoted Cholesky for
robustness against the diffuse-aux linear dependence).
5. Solve L · X = T.reshape(naux, nao*nao) → Lpq.
"""
aux_basis = make_aux_basis_set(ao_basis, aux_name=aux_name)
M = compute_2c_eri_lattice(aux_basis, system, lat_opts)
T = compute_3c_eri_lattice(ao_basis, aux_basis, system, lat_opts)
# Pivoted Cholesky tolerates near-singular M (diffuse aux).
from scipy.linalg import cho_factor, cho_solve
L_chol, low = cho_factor(M, lower=True)
Lpq = cho_solve((L_chol, low), T.reshape(M.shape[0], -1))
return Lpq.reshape(M.shape[0], T.shape[0], T.shape[1])
6. Validation¶
Hard test: compare _lpq_tensor_native(cell, basis) to the existing
_lpq_tensor_from_pyscf(cell) elementwise. Should match to ~1e-10
(Cholesky residual, identical aux basis, identical integrals).
Then drop the PySCF Lpq path. The full SCF chain becomes self-hosted:
S, T, V_ne from vibe-qc compute_*_lattice (already)
Lpq from native vibe-qc 3c/2c (shipped for Γ)
cell blocks from compute_*_eri_lattice_blocks (multi-k storage boundary)
J, K from native einsum on Lpq (already for Γ)
V_xc from native libxc on Becke grids
PySCF and CRYSTAL are no longer in-process periodic backends. They are external reference programs whose outputs are parsed for parity checks. The remaining native multi-k GDF work is the k-dependent metric/tensor phase assembly, IBZ-weighted J/K contractions, and disk-backed storage.
Estimated effort¶
3c/2c molecular bindings: 2–3 hours.
Periodic versions with Schwarz screening: 4–6 hours.
Pybind bindings + CMake: 30 min.
Aux basis manager + JKfit table loading: 2–3 hours.
Lpq builder + Cholesky: 1 hour.
Validation + debug: 4–8 hours (typical: aux normalization, linear- dep handling for diffuse bases, Schwarz threshold tuning).
Total: 1.5–2 focused days. Doable in one follow-up session.
Auxiliary basis library — what to ship¶
For sto-3g and minimal bases: PySCF auto-generates “even-tempered” aux when no explicit aux is registered. We can do the same: emit even-tempered Gaussians from the AO exponent range. Quick fix that unblocks the full sweep.
For the Bredow-group periodic bases (pob-tzvp, pob-tzvp-rev2): we
already have auxiliary basis design as a paper-worthy item (see
memory: project_pob_tzvp_aux_basis.md). Ship a prototype
auto-aux for v0.7.x; the proper hand-tuned aux is a separate
deliverable.
For def2-* bases: copy PySCF’s def2-jkfit / def2-jkfit-tight tables
into vibe-qc’s basis_library/aux/.
Slice 3 — modrho compensation is required¶
The image-summed periodic 2c metric M_PQ = Σ_T (P_0 | Q_T) on a
non-charge-compensated aux basis is mathematically divergent in
3D — the leading monopole-monopole term sums as Σ 1/|T| over a 3D
lattice, which is the classical Madelung divergence. This was an
unresolved question at design time; the empirical confirmation lives
in examples/debug/scratch_lpq_native_smoke.py and
examples/debug/scratch_lpq_native_energy.py. On MgO/sto-3g with
def2-universal-jkfit (PySCF’s auto-pick):
cutoff (bohr) |
‖M‖_F |
min eig(M) |
Cholesky |
|---|---|---|---|
4 |
1.6e+3 |
+4.8e−6 |
✓ |
8 |
6.0e+3 |
−7.6e+1 |
✗ |
12 |
1.2e+4 |
−2.2e+1 |
✗ |
16 |
1.8e+4 |
−9.5e+0 |
✗ |
20 |
3.4e+4 |
−4.4e+0 |
✗ |
‖M‖_F grows monotonically with cutoff (no convergence) and the metric loses positive-definiteness past the Cholesky-OK cutoff (~4 bohr — barely larger than the 8 bohr cubic cell, so the SR aux contributions dominate while the LR divergence has not yet kicked in).
Iter-1 SCF energy with the SAD initial density:
Lpq source |
E_iter1 (Ha) |
comment |
|---|---|---|
(A) PySCF Lpq baseline |
−1084.108 |
converges to −1085.231 |
(B) Native cut=4 (Cholesky) |
−270.229 |
off by ~800 Ha |
(C) Native cut=20 (SVD pseudo) |
−12,653.152 |
catastrophic |
so the bare lattice sum is unusable for SCF. Modrho compensation is structurally required, not an optimisation.
Modrho — algorithmic note¶
PySCF’s pyscf.pbc.df.df.make_modrho_basis rescales each contracted
aux shell so that its monopole moment is √(1/(4π)):
s_i = Σ_p c[p, i] · gaussian_int(2L+2, α_p)
c[p, i] *= √(1/(4π)) / s_i
For l > 0 the renormalisation is conventional (the actual electric
monopole vanishes by symmetry); for l = 0 it caps each S-shell’s
monopole at the same finite value. Combined with PySCF’s separate
charge-compensation on the AO-pair side
(pyscf.pbc.df.df_builder._CCGDFBuilder.make_j3c, smooth Gaussians
that cancel the AO pair’s monopole), the resulting (modified-aux |
compensated-AO-pair) integral converges in real space.
For vibe-qc to reproduce this:
Ship a way to construct a
BasisSetwith custom contraction coefficients. Two options:(preferred) Add
BasisSet(Molecule, vector<ShellInfo>)C++ constructor that buildslibint2::Shells directly. Bypasses the.g94round-trip; avoids file I/O races; clean API.(workaround) Write a temp
.g94file underpython/vibeqc/basis_library/basis/<unique_name>.g94with denormalised coefficients, load viaBasisSet(mol, name), clean up. Works without C++ changes but ugly. The denormalisation dance (libint applies primitive norm at load; PySCF’s modrho coeffs are post-normalised; the conversion factor must be exact) is error-prone.
Implement modrho in
vibeqc.aux_basisusing vibe-qc’s owngaussian_int(2l+2, α)helper (port the 3-line PySCF function). Apply per-shell rescaling on the libint-internal coefficients.Implement compensated-AO-pair handling on the 3c side. The compensating smooth Gaussians become a separate
compcellBasisSet; the periodic 3c is then computed on(aux | AO_pair − comp)and the missing(aux | comp)piece is added back analytically (single G-mesh sum, see PySCF’s_CCGDFBuilder.make_j3c~600 LOC for the canonical algorithm).
Estimated additional effort: 1–2 days (modrho + compcell + a Python-side reciprocal-space sum for the compensating term + parity validation). The slice-1 C++ kernels are reusable as-is.
Resumption checklist for the next session¶
Read this section + check the live periodic-SCF chats in
vibeqc-queuefor current state; the in-treedocs/handover_periodic_scf_followup.mdbrief was removed in the 2026-05-17 archive sweep (recover viagit show HEAD~:docs/handover_periodic_scf_followup.md).Confirm slice-1 baseline still passes:
.venv/bin/python examples/debug/scratch_aux_eri_lattice_smoke.py.Confirm slice-3b (modrho) reduces metric ill-conditioning by ~2 orders of magnitude but does NOT make the metric SPD past cutoff=4 bohr — this is the open question for slice 3c–d.
Update 2026-05-09 — slices 3a and 3b shipped¶
Slice 3a (BasisSet from explicit ShellInfo) — committed
(0ed5854). Adds BasisSet(molecule, shells, name, coefficients_pre_normalized=True) C++ constructor + Python-mutable
ShellInfo so modrho can run in pure Python without .g94 file
round-trips. Round-trip on H2/sto-3g shows S and ERI agree exactly;
scaling all coefficients by 2 produces overlap × 4, validating that
the no-renorm path is honoured.
Slice 3b (modrho normalisation) — committed (5778e39). Direct
port of PySCF make_modrho_basis:
s_i = Σ_p c[p, i] · gaussian_int(2L+2, α_p)
c[p, i] *= √(1/(4π)) / s_i
plus an optional drop_eta primitive cull. Wired into
make_aux_basis_set via compensate=True (default).
Empirical effect on MgO/sto-3g/def2-universal-jkfit (image-summed 2c metric, increasing cutoff):
cutoff (bohr) |
min eig(M) raw |
min eig(M) modrho |
min eig(M) modrho+drop=0.2 |
|---|---|---|---|
4 |
+4.8e−6 |
+3.5e−7 |
+5.3e−6 |
8 |
−7.6e+1 |
−7.2e−1 |
−3.6e−1 |
12 |
−2.2e+1 |
−1.0e−1 |
−2.3e−2 |
16 |
−9.5e+0 |
−2.6e−2 |
−4.5e−3 |
20 |
−4.4e+0 |
−8.6e−3 |
−4.7e−4 |
Modrho improves conditioning by ~2 orders of magnitude, but the metric remains non-SPD past cut=4 bohr. The 3D Madelung divergence is not cured by normalisation alone — this is the expected behaviour per the PySCF algorithm structure: modrho is a normalisation convention that simplifies downstream algebra; the actual convergence treatment lives in the AO-pair-side compensating-charge mechanism (slice 3d below).
Slice 3c–d — open algorithmic questions¶
Two paths are plausible from here; the literature sweep commissioned in parallel should resolve which is correct:
(i) Per-shell precision-truncated lattice cutoff¶
Hypothesis: PySCF’s pbc_intor("int2c2e") on the modrho’d auxcell
returns a finite matrix (max |M| ≈ 20 on MgO/sto-3g) by using
its cell.precision-controlled image cutoff via
_estimate_rcut(es, l, c_max, precision). Each aux shell contributes
to images only out to its own precision-justified radius; diffuse
primitives have small radii so their long-range divergent
contribution is naturally truncated.
If true, vibe-qc’s bare 2c lattice sum on modrho aux + per-shell
rcut should reproduce PySCF’s pbc_intor("int2c2e") matrix to
~1e-10. Then the metric is “PSD by truncation” — physically accurate
to the precision target, not to infinity.
Implementation cost: small. Add per-shell-pair rcut to the cell loop
in compute_2c_eri_lattice / compute_3c_eri_lattice.
(ii) Full AO-pair compensating-charge mechanism¶
The textbook fix per Sun et al. 2017 § II.B: introduce a “compcell”
of smooth Gaussians (one per atom × per l, with global
smooth_eta). For each AO pair (μ, ν) at center A, compute its
multipole moments; subtract a matching combination of compcell
Gaussians; compute (aux | compensated AO pair) in real space
(converges); add back analytical (aux | smooth-Gaussian) terms in
reciprocal space.
Substantially more code:
Compcell
BasisSetconstruction (make_chgcellin PySCF).AO-pair multipole moments (libint
emultipoleN— already used incompute_dipole; extend to higher l).(aux | compcell) integrals — special case of the existing 3c lattice with compcell as the orbital pair.
Reciprocal-space (aux | smooth) sum — small G-mesh, analytic Fourier transforms of Gaussians.
Estimate: 2–3 days, plus parity validation.
Decision rule¶
Try (i) first — small change, may suffice for sub-µHa parity if our hypothesis about PySCF’s truncation is right. If (i) doesn’t reach sub-µHa, the lit sweep’s PySCF algorithm walkthrough should clarify exactly what (ii) needs to look like.
Resumption checklist (updated 2026-05-09)¶
Read this section + the handover docs.
Verify slices 1–3b still work:
examples/debug/scratch_aux_eri_lattice_smoke.py(slice 1 structural check).Quick modrho check via
make_aux_basis_set(mol, aux_name="def2-universal-jkfit", compensate=True)and a manual ‖M‖_F sweep — should reproduce the table above.
Implement per-shell rcut (path (i) — start here).
Compare vibe-qc’s 2c metric to PySCF’s
auxcell.pbc_intor(int2c2e)elementwise. If matched to ~1e-10, ship as slice 3c.If not matched, defer to lit-sweep results for the proper compcell algorithm and implement path (ii) as slice 3d.
Once Lpq parity is achieved, resume slices 4 (V_ne via aux), 5 (driver rewrite), 6 (
_basis_g94cleanup), 7 (full parity sweep), 8 (docs + PR).