Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -161,23 +161,28 @@ class SCFAlgorithm {
* Converts spin-blocked Fock/density matrices into the effective ROHF Fock
* and total-density representation used for OG evaluation.
*
* When the MO coefficient matrix @p C is rectangular (n_MO < n_AO due to
* linear-dependency removal) the back-transform uses the overlap-mediated
* projection S C F_eff_MO C^T S instead of the square-matrix inversion,
* matching the RHF/UHF treatment in the reduced MO space.
*
* @param[in] F Spin-blocked Fock matrix in AO basis with alpha and beta
* blocks stacked by row
* @param[in] C Molecular-orbital coefficient matrix used for AO<->MO
* transformations
* @param[in] P Spin-blocked density matrix in AO basis with alpha and beta
* blocks stacked by row
* @param[in] S AO overlap matrix; used for the back-transform when
* n_MO < n_AO
* @param[in] nelec_alpha Number of alpha electrons
* @param[in] nelec_beta Number of beta electrons
* @param[out] effective_fock Effective ROHF Fock matrix in AO basis
* @param[out] total_density Total AO density matrix (P_alpha + P_beta)
*/
static void build_rohf_f_p_matrix(const RowMajorMatrix& F,
const RowMajorMatrix& C,
const RowMajorMatrix& P, int nelec_alpha,
int nelec_beta,
RowMajorMatrix& effective_fock,
RowMajorMatrix& total_density);
static void build_rohf_f_p_matrix(
const RowMajorMatrix& F, const RowMajorMatrix& C, const RowMajorMatrix& P,
const RowMajorMatrix& S, int nelec_alpha, int nelec_beta,
RowMajorMatrix& effective_fock, RowMajorMatrix& total_density);

/**
* @brief Access cached ROHF effective Fock matrix
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,26 +238,36 @@ SCFAlgorithm::build_rohf_convergence_matrices(const SCFImpl& scf_impl) {
const auto nelec_vec = scf_impl.get_num_electrons();
build_rohf_f_p_matrix(
scf_impl.get_fock_matrix(), scf_impl.get_orbitals_matrix(),
scf_impl.get_density_matrix(), nelec_vec[0], nelec_vec[1],
rohf_effective_fock_, rohf_total_density_);
scf_impl.get_density_matrix(), scf_impl.overlap(), nelec_vec[0],
nelec_vec[1], rohf_effective_fock_, rohf_total_density_);
Comment thread
nabbelbabbel marked this conversation as resolved.
Comment thread
nabbelbabbel marked this conversation as resolved.

return {get_rohf_convergence_fock_matrix(),
get_rohf_convergence_density_matrix()};
}

void SCFAlgorithm::build_rohf_f_p_matrix(const RowMajorMatrix& F,
const RowMajorMatrix& C,
const RowMajorMatrix& P,
int nelec_alpha, int nelec_beta,
RowMajorMatrix& effective_fock,
RowMajorMatrix& total_density) {
void SCFAlgorithm::build_rohf_f_p_matrix(
const RowMajorMatrix& F, const RowMajorMatrix& C, const RowMajorMatrix& P,
const RowMajorMatrix& S, int nelec_alpha, int nelec_beta,
RowMajorMatrix& effective_fock, RowMajorMatrix& total_density) {
QDK_LOG_TRACE_ENTERING();
const int num_atomic_orbitals = static_cast<int>(C.rows());
const int num_molecular_orbitals = static_cast<int>(C.cols());
if (num_atomic_orbitals != num_molecular_orbitals) {
// Linear-dependency removal may yield nMO < nAO; nMO > nAO is always wrong.
if (num_molecular_orbitals > num_atomic_orbitals) {
throw std::invalid_argument(
"ROHF build: number of molecular orbitals cannot exceed number of "
"atomic orbitals!");
}
Comment thread
wavefunction91 marked this conversation as resolved.
if (S.rows() != num_atomic_orbitals || S.cols() != num_atomic_orbitals) {
throw std::invalid_argument(
"ROHF build requires number of atomic orbitals to equal number of "
"molecular orbitals!");
"ROHF build: overlap matrix S must be square with dimension equal "
"to the number of atomic orbitals!");
}
Comment thread
nabbelbabbel marked this conversation as resolved.
if (nelec_alpha > num_molecular_orbitals ||
nelec_beta > num_molecular_orbitals) {
throw std::invalid_argument(
"ROHF build: electron counts exceed the number of molecular "
"orbitals; nd/ns/nv block indices would be out of bounds!");
}

total_density =
Expand Down Expand Up @@ -324,42 +334,51 @@ void SCFAlgorithm::build_rohf_f_p_matrix(const RowMajorMatrix& F,
copy_block(F_up_mo, nd, nd + ns, ns, nv);
copy_block(F_up_mo, nd + ns, nd, nv, ns);

// Transform the effective Fock matrix back to AO basis by solving
// C^{-T} * F_MO * C^{-1} = F_AO
// We use LAPACK's getrf/getrs to solve the linear systems involving C^T and
// C without explicitly inverting C
const int matrix_dim = num_molecular_orbitals;
using ColMajorMatrix =
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, Eigen::ColMajor>;
// LAPACK expects column-major layout, so we copy the row-major data into a
// column-major matrix without transposing the logical layout
ColMajorMatrix Ct =
Eigen::Map<const ColMajorMatrix>(C.data(), matrix_dim, C.rows());
// F_MO is symmetric, so we can use it directly as the right-hand side
// without transposing
ColMajorMatrix temp_rhs = effective_F_mo;
std::vector<int64_t> ipiv(matrix_dim);

auto info =
lapack::getrf(matrix_dim, matrix_dim, Ct.data(), matrix_dim, ipiv.data());
if (info != 0) {
throw std::runtime_error("getrf failed while factorizing C^T");
}

info = lapack::getrs(lapack::Op::NoTrans, matrix_dim, matrix_dim, Ct.data(),
matrix_dim, ipiv.data(), temp_rhs.data(), matrix_dim);
if (info != 0) {
throw std::runtime_error("getrs failed while solving C^T X = F_mo");
}

temp_rhs.transposeInPlace();
info = lapack::getrs(lapack::Op::NoTrans, matrix_dim, matrix_dim, Ct.data(),
matrix_dim, ipiv.data(), temp_rhs.data(), matrix_dim);
if (info != 0) {
throw std::runtime_error("getrs failed while solving C^T X = M^T");
if (num_atomic_orbitals == num_molecular_orbitals) {
// Square case (nAO == nMO): back-transform via C^{-T} * F_MO * C^{-1}.
const int matrix_dim = num_molecular_orbitals;
using ColMajorMatrix =
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, Eigen::ColMajor>;
ColMajorMatrix Ct =
Eigen::Map<const ColMajorMatrix>(C.data(), matrix_dim, C.rows());
ColMajorMatrix temp_rhs = effective_F_mo;
std::vector<int64_t> ipiv(matrix_dim);

auto info = lapack::getrf(matrix_dim, matrix_dim, Ct.data(), matrix_dim,
ipiv.data());
if (info != 0) {
throw std::runtime_error("getrf failed while factorizing C^T");
}
info = lapack::getrs(lapack::Op::NoTrans, matrix_dim, matrix_dim, Ct.data(),
matrix_dim, ipiv.data(), temp_rhs.data(), matrix_dim);
if (info != 0) {
throw std::runtime_error("getrs failed while solving C^T X = F_mo");
}
temp_rhs.transposeInPlace();
info = lapack::getrs(lapack::Op::NoTrans, matrix_dim, matrix_dim, Ct.data(),
matrix_dim, ipiv.data(), temp_rhs.data(), matrix_dim);
if (info != 0) {
throw std::runtime_error("getrs failed while solving C^T X = M^T");
}
effective_fock = temp_rhs.transpose();
} else {
// Rectangular case (nMO < nAO): overlap-mediated projection
// F_eff_AO = S * C * F_MO_eff * C^T * S = SC * F_MO_eff * SC^T
// Proof: C^T F_eff_AO C = (C^T S C) F_MO_eff (C^T S C) = F_MO_eff
// when C^T S C = I. This mirrors the RHF/UHF treatment in the
// reduced MO space.
RowMajorMatrix SC(num_atomic_orbitals, num_molecular_orbitals);
Comment thread
wavefunction91 marked this conversation as resolved.
SC.noalias() = S * C;
// SC^T is nMO x nAO; similarity_transform computes
// (SC^T)^T * F_MO_eff * SC^T = SC * F_MO_eff * SC^T via blas::gemm.
RowMajorMatrix SC_T(num_molecular_orbitals, num_atomic_orbitals);
SC_T.noalias() = SC.transpose();
similarity_transform(blas::Layout::RowMajor, num_atomic_orbitals,
num_molecular_orbitals, 1.0, SC_T.data(),
num_atomic_orbitals, effective_F_mo.data(),
num_molecular_orbitals, 0.0, effective_fock.data(),
num_atomic_orbitals, &atba_workspace);
}

effective_fock = temp_rhs.transpose();
if (!effective_fock.isApprox(effective_fock.transpose())) {
QDK_LOGGER().warn(
"Effective Fock matrix in AO is far from symmetric. Symmetrizing...");
Expand Down
92 changes: 92 additions & 0 deletions cpp/tests/test_scf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,98 @@ TEST_F(ScfTest, OH_ROKS_invalid) {
EXPECT_THROW(scf_solver->run(oh, 0, 2, "sto-3g"), std::invalid_argument);
}

// Regression test for GitHub issue #543.
Comment thread
nabbelbabbel marked this conversation as resolved.
// ROHF crashed with "ROHF build requires number of atomic orbitals to equal
// number of molecular orbitals!" when n_MO < n_AO due to basis linear
// dependence. The fix replaces the square-matrix inversion with the
// overlap-mediated projection F_eff_AO = S C F_MO_eff C^T S.
// Deterministic check of the projection identity used in the rectangular
// (nMO < nAO) ROHF back-transform:
// if C^T S C = I, then C^T F_eff_AO C = F_MO_eff.
TEST_F(ScfTest, ROHF_RectangularBackTransform_ProjectionIdentity) {
Comment on lines +231 to +235

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated the comment to accurately describe what the test does: it checks the algebraic projection identity C^T F_eff_AO C = F_MO_eff when C^T S C = I, not the production SCFAlgorithm::build_rohf_f_p_matrix code path directly.

using Mat =
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, Eigen::RowMajor>;
const int nAO = 4; // atomic orbitals
const int nMO = 2; // molecular orbitals (nMO < nAO = rectangular case)

// Use a non-identity overlap so the test fails if the implementation omits S.
Mat S = Mat::Identity(nAO, nAO);
S(0, 0) = 1.2;
S(1, 1) = 0.7;
S(2, 2) = 1.1;
S(3, 3) = 0.9;

// coeff: nAO x nMO with S-orthonormal columns (C^T S C = I)
Mat coeff = Mat::Zero(nAO, nMO);
coeff(0, 0) = 1.0 / std::sqrt(S(0, 0));
coeff(1, 1) = 1.0 / std::sqrt(S(1, 1));

// Arbitrary symmetric F_MO_eff in MO space
Mat F_mo = Mat::Zero(nMO, nMO);
F_mo(0, 0) = 2.0;
F_mo(0, 1) = 0.5;
F_mo(1, 0) = 0.5;
F_mo(1, 1) = 3.0;

// Compute F_eff_AO = S * coeff * F_mo * coeff^T * S
Mat SC = S * coeff;
Mat F_ao = SC * F_mo * SC.transpose();

// Verify projection identity: coeff^T * F_ao * coeff = F_mo
Mat recovered = coeff.transpose() * F_ao * coeff;
EXPECT_TRUE(recovered.isApprox(F_mo, 1e-12))
<< "Projection identity C^T F_eff_AO C = F_MO_eff failed.\n"
<< "recovered:\n"
<< recovered << "\nexpected:\n"
<< F_mo;
}

TEST_F(ScfTest, ROHF_LinearlyDependentBasis_Issue543) {
auto structure = testing::create_obenzosemiquinone_structure();
auto scf_solver = ScfSolverFactory::create();
scf_solver->settings().set("method", "hf");
scf_solver->settings().set("scf_type", "restricted");
scf_solver->settings().set("enable_gdm", false);

// On some platforms linear-dependency removal drops so many functions
// that nMO < nelec_alpha; the solver correctly throws in that case.
double energy = 0.0;
std::shared_ptr<Wavefunction> wfn;
try {
auto result = scf_solver->run(structure, 0, 2, "def2-tzvp");
energy = result.first;
wfn = result.second;
} catch (const std::invalid_argument& e) {
const std::string msg = e.what();
if (msg.find("electron counts exceed the number of molecular "
"orbitals") != std::string::npos) {
GTEST_SKIP() << "Basis too linearly dependent for electron count on "
"this platform: "
<< msg;
}
FAIL() << "Unexpected std::invalid_argument: " << msg;
}
Comment thread
wavefunction91 marked this conversation as resolved.
ASSERT_NE(wfn, nullptr);
const auto orbitals = wfn->get_orbitals();
ASSERT_NE(orbitals, nullptr);

// Always validate the basic contract from the issue report.
EXPECT_TRUE(orbitals->is_restricted());
EXPECT_TRUE(std::isfinite(energy));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi adithyaphanithota (@Adithyaphani) , is it possible for you to add your reference energy at here? It would be very helpful for us to make the comparison. Thank you!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Boqin Zhang (@BoqinZhang), happy to add it! The reference energy was omitted because the test skips on most platforms when linear-dependency removal doesn't fire. Could you share the converged ROHF/def2-tzvp energy from your infrastructure? We'll add the EXPECT_NEAR right away.


// Use non-deprecated API: coefficients() returns SymmetryBlockedTensor.
// For restricted orbitals, the alpha/alpha block holds the shared AO-MO
// coefficients.
const auto& coeff_alpha =
orbitals->coefficients()->block({axes::alpha(), axes::alpha()});
if (coeff_alpha.rows() == coeff_alpha.cols()) {
GTEST_SKIP() << "Linear-dependency removal did not trigger; expected "
"nMO < nAO";
}
EXPECT_GT(coeff_alpha.rows(), coeff_alpha.cols());
// Reference energy intentionally omitted — needs confirmed converged value.
}

TEST_F(ScfTest, Oxygen_atom_gdm) {
auto oxygen = testing::create_oxygen_structure();
auto scf_solver = ScfSolverFactory::create();
Expand Down
30 changes: 30 additions & 0 deletions cpp/tests/ut_common.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,34 @@ inline std::shared_ptr<Structure> create_agh_structure() {
return std::make_shared<Structure>(coords, elements);
}

/**
* @brief Creates an o-benzosemiquinone radical structure (issue #543)
*
* Planar aromatic doublet radical. With def2-tzvp this geometry produces
* a linearly dependent AO basis (n_MO < n_AO), exercising the rectangular
* ROHF back-transform path.
*/
inline std::shared_ptr<Structure> create_obenzosemiquinone_structure() {
std::vector<Eigen::Vector3d> coords = {
{3.7321, 1.3450, 0.0000}, {2.0000, 0.3450, 0.0000},
{3.7321, 0.3450, 0.0000}, {2.8660, -0.1550, 0.0000},
{4.5981, -0.1550, 0.0000}, {2.8660, -1.1550, 0.0000},
{4.5981, -1.1550, 0.0000}, {3.7321, -1.6550, 0.0000},
{5.1350, 0.1550, 0.0000}, {2.3291, -1.4650, 0.0000},
{5.1350, -1.4650, 0.0000}, {3.7321, -2.2750, 0.0000},
{4.2690, 1.6550, 0.0000}};

// Convert to Bohr
for (auto& coord : coords) {
coord *= qdk::chemistry::constants::angstrom_to_bohr;
}

std::vector<Element> elements = {
Element::O, Element::O, Element::C, Element::C, Element::C,
Element::C, Element::C, Element::C, Element::H, Element::H,
Element::H, Element::H, Element::H};

return std::make_shared<Structure>(coords, elements);
}

} // namespace testing
60 changes: 60 additions & 0 deletions python/tests/test_scf.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ def create_oxygen_structure():
return Structure(symbols, coords)


def create_obenzosemiquinone_structure():
"""Create o-benzosemiquinone radical structure (issue #543).

Planar aromatic doublet radical. With def2-tzvp this geometry produces
a linearly dependent AO basis (n_MO < n_AO), exercising the rectangular
ROHF back-transform path.
"""
symbols = ["O", "O", "C", "C", "C", "C", "C", "C", "H", "H", "H", "H", "H"]
coords = (
np.array(
[
[3.7321, 1.3450, 0.0000],
[2.0000, 0.3450, 0.0000],
[3.7321, 0.3450, 0.0000],
[2.8660, -0.1550, 0.0000],
[4.5981, -0.1550, 0.0000],
[2.8660, -1.1550, 0.0000],
[4.5981, -1.1550, 0.0000],
[3.7321, -1.6550, 0.0000],
[5.1350, 0.1550, 0.0000],
[2.3291, -1.4650, 0.0000],
[5.1350, -1.4650, 0.0000],
[3.7321, -2.2750, 0.0000],
[4.2690, 1.6550, 0.0000],
]
)
* ANGSTROM_TO_BOHR
)
return Structure(symbols, coords)


class TestScfSolver:
"""Test class for SCF solver functionality."""

Expand Down Expand Up @@ -484,3 +515,32 @@ def test_scf_solver_oxygen_atom_invalid_bfgs_history_size_limit_gdm(self):
# Test that invalid history size limit throws a ValueError (std::invalid_argument in C++)
with pytest.raises(ValueError, match="GDM history size limit must be at least"):
scf_solver.run(oxygen, 0, 1, "cc-pvdz") # singlet state

def test_rohf_linearly_dependent_basis_issue_543(self):
"""ROHF must not raise when n_MO < n_AO (regression for issue #543)."""
structure = create_obenzosemiquinone_structure()
scf_solver = algorithms.create("scf_solver")
scf_solver.settings().set("method", "hf")
scf_solver.settings().set("scf_type", "restricted")
scf_solver.settings().set("enable_gdm", False)

# On some platforms linear-dependency removal drops so many
# functions that nMO < nelec_alpha; the solver correctly throws.
try:
energy, wavefunction = scf_solver.run(structure, 0, 2, "def2-tzvp")
except ValueError as e:
if "electron counts exceed the number of molecular orbitals" in str(e):
pytest.skip("Basis too linearly dependent for electron count on this platform")
raise
orbitals = wavefunction.get_orbitals()
# Always validate the basic contract from the issue report.
assert orbitals.is_restricted()
assert np.isfinite(energy)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi adithyaphanithota (@Adithyaphani) , Like the cpp test, is it possible for you to add your reference energy at here? It would be helpful for us to make a comparison. Thank you!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boqin Zhang (@BoqinZhang) Same situation here — happy to add np.isclose once we have a confirmed converged value. Could you run the o-benzosemiquinone ROHF/def2-tzvp calculation on your end and share the energy? We'll update both tests immediately.


coeffs_alpha, _ = orbitals.get_coefficients()

# Ensure linear-dependency removal actually fired (n_MO < n_AO).
if coeffs_alpha.shape[0] == coeffs_alpha.shape[1]:
pytest.skip("Linear-dependency removal did not trigger; expected n_MO < n_AO")
assert coeffs_alpha.shape[0] > coeffs_alpha.shape[1] # nAO > nMO
# Reference energy intentionally omitted — needs confirmed converged value.
Comment on lines +542 to +546

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Added a deterministic unit test ROHF_RectangularBackTransform_ProjectionIdentity that directly verifies the projection identity C^T * F_eff_AO * C = F_MO_eff with a synthetic 4×2 rectangular C and known F_MO — no SCF run needed, so it can't silently pass with wrong math. The integration test (ROHF_LinearlyDependentBasis_Issue543) keeps the reference energy as TBD pending a confirmed converged value.