Repository: pyscf/pyscf — pull request #2305 (python)
Title: Fix bugs in complex-valued contractions for fci.direct_nosym (fix #2302)

Additionally, update grad.sacasscf due to API changes in scipy-1.14

Resolved issues:
title: contract_2e gives inconsistent results on complex vector
body: The following code generates a two-body tensor and a random complex-valued FCI vector, and then contracts the two-body tensor with the vector in two ways:
- Directly using `contract_2e`
- Using `contract_2e` on the real and imaginary parts of the vector and adding the results

These should give the same result, but they don't.

```python
import math

import numpy as np
import pyscf
import pyscf.mcscf
from pyscf.fci.direct_nosym import absorb_h1e, contract_2e

# Generate a two-body tensor
mol = pyscf.gto.Mole()
mol.build(
    atom=[["H", (0, 0, 0)], ["H", (1.0, 0, 0)]],
    basis="sto-6g",
    symmetry="Dooh",
    verbose=0,
)
scf = pyscf.scf.RHF(mol).run()
n_electrons = int(sum(scf.mo_occ))
n_alpha = (n_electrons + mol.spin) // 2
n_beta = (n_electrons - mol.spin) // 2
nelec = (n_alpha, n_beta)
norb = mol.nao_nr()
cas = pyscf.mcscf.CASCI(scf, norb, nelec)
h1e, _ = cas.get_h1cas()
h2e = cas.get_h2cas()
eri = absorb_h1e(h1e, h2e, norb, nelec, 0.5)

# Generate a random state vector
rng = np.random.default_rng(1234)
dim = math.comb(norb, n_alpha) * math.comb(norb, n_beta)
vec = rng.standard_normal(dim) + 1j * rng.standard_normal(dim)
vec /= np.linalg.norm(vec)

# Contract directly
result = contract_2e(eri, vec, norb, nelec)
# Contract real and imaginary parts separately
result_alt = contract_2e(eri, vec.real, norb, nelec) + 1j * contract_2e(
    eri, vec.imag, norb, nelec
)

print(np.allclose(result, result_alt))
print(np.linalg.norm(result - result_alt))
```
```
False
1.0875338674395465
```

Hints:

Fix patch:
diff --git a/pyscf/fci/direct_nosym.py b/pyscf/fci/direct_nosym.py
index fcd67f5664..913cedd6b0 100644
--- a/pyscf/fci/direct_nosym.py
+++ b/pyscf/fci/direct_nosym.py
@@ -125,7 +125,7 @@ def contract_2e(eri, fcivec, norb, nelec, link_index=None):
     outI += contract_2e(eriI, ciR, norb, nelec, link_index=link_index)
     out = outR.astype(numpy.complex128)
     out.imag = outI
-    return outR
+    return out
 
 def absorb_h1e(h1e, eri, norb, nelec, fac=1):
     '''Modify 2e Hamiltonian to include 1e Hamiltonian contribution.
diff --git a/pyscf/grad/lagrange.py b/pyscf/grad/lagrange.py
index b93707865e..89ff6d99a0 100644
--- a/pyscf/grad/lagrange.py
+++ b/pyscf/grad/lagrange.py
@@ -14,6 +14,7 @@
 # limitations under the License.
 
 import numpy as np
+import scipy
 from scipy import linalg, optimize
 from scipy.sparse import linalg as sparse_linalg
 from pyscf import lib, __config__
@@ -126,9 +127,9 @@ def my_Lvec_last ():
         prec_obj = sparse_linalg.LinearOperator ((self.nlag,self.nlag), matvec=precond,
                                                  dtype=bvec.dtype)
         x0_guess = self.get_init_guess (bvec, Adiag, Aop, precond)
-        Lvec, info_int = sparse_linalg.cg(Aop_obj, -bvec, x0=x0_guess,
-                                          atol=self.conv_atol,
-                                          maxiter=self.max_cycle, callback=my_call, M=prec_obj)
+        Lvec, info_int = _cg(Aop_obj, -bvec, x0=x0_guess,
+                             tol=self.conv_rtol, atol=self.conv_atol,
+                             maxiter=self.max_cycle, callback=my_call, M=prec_obj)
         logger.info (self, ('Lagrange multiplier determination {} after {} iterations\n'
                             '   |geff| = {}, |Lvec| = {}').format (
                                 'converged' if info_int == 0 else 'not converged',
@@ -190,3 +191,10 @@ def __call__(self, x):
         Adiagd[abs(Adiagd)<1e-8] = 1e-8
         x /= Adiagd
         return x
+
+if scipy.__version__.split('.')[1] >= '14': # scipy 1.14
+    def _cg(A, b, x0=None, *, tol=1e-05, atol=0.0, maxiter=None, M=None, callback=None):
+        return sparse_linalg.cg(A, b, x0, rtol=tol, atol=atol, maxiter=maxiter,
+                                M=M, callback=callback)
+else:
+    _cg = sparse_linalg.cg


Test patch:
diff --git a/pyscf/fci/test/test_direct_nosym.py b/pyscf/fci/test/test_direct_nosym.py
index d3beef460c..cda504ded4 100644
--- a/pyscf/fci/test/test_direct_nosym.py
+++ b/pyscf/fci/test/test_direct_nosym.py
@@ -60,6 +60,9 @@ def test_contract_complex(self):
         ci1 += 1j * fci.direct_nosym.contract_2e(h2e, ci0.imag, norb, nelec)
         self.assertTrue(numpy.allclose(ci1ref, ci1))
 
+        ci1 = fci.direct_nosym.contract_2e(h2e, ci0, norb, nelec).astype(complex)
+        self.assertAlmostEqual(abs(ci1ref - ci1).max(), 0, 12)
+
     def test_absorb_h1e(self):
         href = fci_slow.absorb_h1e(h1e, h2e, norb, nelec)
         h1 = fci.direct_nosym.absorb_h1e(h1e, h2e, norb, nelec)